mirror of
https://github.com/cline/cline.git
synced 2026-09-01 15:11:04 +08:00
Compare commits
178 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7427994fba | |||
| 4b90ec3596 | |||
| a1ba0fd5ac | |||
| 366ffe8512 | |||
| d7a225ff34 | |||
| 126d953f45 | |||
| 941414e87f | |||
| 1af57b7c62 | |||
| b057710083 | |||
| 0096521966 | |||
| 1f50188c41 | |||
| d00103419f | |||
| 1c9bbba749 | |||
| b54db8b82d | |||
| 7c7e86d055 | |||
| 458583a476 | |||
| f76ec25559 | |||
| 90e9c49654 | |||
| 0ea8506bf9 | |||
| 1c22ee5896 | |||
| 30857e969e | |||
| 0d07b421df | |||
| ff9484e141 | |||
| ccc8e471e3 | |||
| 9859136e80 | |||
| 7969ba2d68 | |||
| 16c0992672 | |||
| 3b8be75c7f | |||
| a00f24e182 | |||
| 238654e6a2 | |||
| 643319f106 | |||
| 0645eccd2a | |||
| 3a6f0c2fd0 | |||
| 17314cb88d | |||
| 6f9cf8a028 | |||
| 47aecdfc75 | |||
| fb037a05c1 | |||
| d9cedc41b7 | |||
| f978ecce52 | |||
| 4c28760557 | |||
| ba79a51dd7 | |||
| 4e5cc92065 | |||
| b42c0f2571 | |||
| 386d5e41e7 | |||
| 2823e6c845 | |||
| cef9af16a4 | |||
| 49d3bcfedc | |||
| d36a44ec38 | |||
| cbcf89d634 | |||
| 6a10e30436 | |||
| 2c0afbc3be | |||
| 2ef4e56bca | |||
| e26d001585 | |||
| 6fc2cb128e | |||
| 8cc64f5e7e | |||
| a4412e8014 | |||
| 36f7abb8ec | |||
| 080a79bd7d | |||
| 0208fdf555 | |||
| 226f20f28f | |||
| fdc76c8802 | |||
| 7099a00674 | |||
| b470229a97 | |||
| e37f6e3b88 | |||
| 4c72bd96ab | |||
| be120e85be | |||
| fdd04bc942 | |||
| b7c03af9ac | |||
| 1961583eb6 | |||
| 35dd137c36 | |||
| 867a69777a | |||
| b67afb84a7 | |||
| abca4cc76a | |||
| 73c64d9ab5 | |||
| 989eeb2a87 | |||
| d5524e747a | |||
| 521258239a | |||
| dd25195b4d | |||
| 0b95ad3bae | |||
| 95120bb050 | |||
| dbe5f74884 | |||
| 938f04b28d | |||
| 801946f5ea | |||
| 13b69415fa | |||
| 9298be6d0e | |||
| 807a4b36df | |||
| b4eaf48f44 | |||
| 69b499e7fe | |||
| a981ec7566 | |||
| d8cdd98de1 | |||
| 67cff02892 | |||
| 24a34bdc65 | |||
| 5425220e8e | |||
| 075d82fb90 | |||
| 59dca4f55c | |||
| 8ad21f2cea | |||
| 7c5a082b9a | |||
| 2193847739 | |||
| cc2f8e7a1a | |||
| 192a346840 | |||
| 0066cd8553 | |||
| 7d4889837b | |||
| 8404e74ea1 | |||
| 9d5772c42c | |||
| c7dad1ad13 | |||
| 894fa3562e | |||
| ff762d36dd | |||
| 6e40ec16f3 | |||
| 74307ae886 | |||
| 6f20321231 | |||
| adf4c92f6e | |||
| 21e95ab67e | |||
| cac0309579 | |||
| 8310a3dc23 | |||
| 349a7c26ff | |||
| f21bcb22a6 | |||
| d490029bd3 | |||
| a936a7dd79 | |||
| f19143e35c | |||
| ed16aff1ac | |||
| 6324982592 | |||
| 70ab22098e | |||
| b999fe14af | |||
| e05cd8c8a9 | |||
| e2458b283f | |||
| 80d53e700f | |||
| 16f5d3cd2e | |||
| 301c524458 | |||
| 94ccde51c0 | |||
| 4875a54dcd | |||
| d8af586b31 | |||
| 83e86ddb9f | |||
| 5fd07256f0 | |||
| 1b1d880bf8 | |||
| 272cae33cf | |||
| c721b34652 | |||
| 0eb2762ed2 | |||
| b34056ed87 | |||
| 5f92b138e8 | |||
| 494ffac404 | |||
| 6e5afda275 | |||
| 04e4f25865 | |||
| 8cd50e7a31 | |||
| dd4ac0167c | |||
| 55f7ce9b20 | |||
| a80022795e | |||
| 669286bb8e | |||
| e1f60ce8af | |||
| f948e2bc7b | |||
| 329c269a8c | |||
| a9617a1acb | |||
| 0a691cffbc | |||
| aec21bb299 | |||
| 931279c845 | |||
| 841f3da57f | |||
| 96538309db | |||
| 1efd84a6d6 | |||
| 130922fe99 | |||
| 85d0e6b0b5 | |||
| dff2d59172 | |||
| 9cb6cf52fc | |||
| 766e7da0f4 | |||
| a3dac16a36 | |||
| 185cbe15f4 | |||
| d02ea77ce0 | |||
| af17d6239d | |||
| 06739a82e6 | |||
| c6c49ec8ce | |||
| 8c4b642859 | |||
| 0204396b37 | |||
| e9b6659a4b | |||
| 7a1e757a2c | |||
| ffd80d94df | |||
| 54bb50f195 | |||
| 58a1eae73f | |||
| a295ac2456 | |||
| 1adebe7e13 | |||
| df48b976f6 |
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
Add Bedrock prompt caching support (optional).
|
||||
|
||||
This feature protected under checkbox because it is not yet rolled out to everyone, and if you will try to send cache headers, and its not enabled for you, you will get error.
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
feat(bedrock): adding Amazon Nova
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Improve file handling for NextJS folder naming conventions and increase file listing limits. Fix glob pattern interpretation issues with parentheses in folder names
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
better prompting around downloading mcp servers to not overwrite existing server configs
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Added auto-approve options for edits/reads outside of the users workspace
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Improve diff editing animation and prompts for large files; show diff edits indicator next to file path
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Handle input too large Anthropic
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Add button to open config to MCP toggle modal
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix "See more" not showing up for tasks after task un-fold
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Enhance Ollama provider with retry mechanism, timeout handling, and improved error handling. This change adds robust error handling, automatic retries, timeout handling, and improved stream processing to the Ollama provider, making it more reliable and preventing the infinite "thinking" problem. Tests are now skipped if Ollama is not running locally.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix gpt-4.5-preview's supportsPromptCache value to true
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Added debounce for checkmark expanded ui
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Add more models support for doubao
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Switch clinerules file to a directory
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Refactor: Remove cline rules functions from task/index.ts
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Added a script to create test tasks in dev mode
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Added telemetry for model favoriting
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
updated move context management out of cline
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Add reasoning effort to grok 3 mini
|
||||
@@ -2,4 +2,4 @@
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Can test on WebIDE
|
||||
Remove WeakRef usage
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add model info to the Requesty provider
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
refactor & perf of file ServerRow.tsx
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Added support for SambaNova QwQ-32B model
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
remove unused import of file `BrowserSettingsMenu.tsx`
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Add OpenAI "dynamic" model chatgpt-4o-latest
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Stream Grok 3 mini reasoning tokens
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
DangerButton.tsx to Tailwind
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
refactor & perf of file `ThinkingBudgetSlider.tsx`
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
refactor: type improvement of file `ChatTextArea.tsx`
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
feat(bedrock): adding two regions
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Adds favorite toggles for models when using the Cline & OpenRouter providers
|
||||
@@ -11,10 +11,12 @@ graph TB
|
||||
subgraph VSCode Extension Host
|
||||
subgraph Core Extension
|
||||
ExtensionEntry[Extension Entry<br/>src/extension.ts]
|
||||
ClineProvider[ClineProvider<br/>src/core/webview/ClineProvider.ts]
|
||||
ClineClass[Cline Class<br/>src/core/Cline.ts]
|
||||
WebviewProvider[WebviewProvider<br/>src/core/webview/index.ts]
|
||||
Controller[Controller<br/>src/core/controller/index.ts]
|
||||
Task[Task<br/>src/core/task/index.ts]
|
||||
GlobalState[VSCode Global State]
|
||||
SecretsStorage[VSCode Secrets Storage]
|
||||
McpHub[McpHub<br/>src/services/mcp/McpHub.ts]
|
||||
end
|
||||
|
||||
subgraph Webview UI
|
||||
@@ -27,45 +29,101 @@ graph TB
|
||||
TaskStorage[Task Storage<br/>Per-Task Files & History]
|
||||
CheckpointSystem[Git-based Checkpoints]
|
||||
end
|
||||
|
||||
subgraph API Providers
|
||||
AnthropicAPI[Anthropic]
|
||||
OpenRouterAPI[OpenRouter]
|
||||
BedrockAPI[AWS Bedrock]
|
||||
OtherAPIs[Other Providers]
|
||||
end
|
||||
|
||||
subgraph MCP Servers
|
||||
ExternalMcpServers[External MCP Servers]
|
||||
end
|
||||
end
|
||||
|
||||
%% Core Extension Data Flow
|
||||
ExtensionEntry --> ClineProvider
|
||||
ClineProvider --> ClineClass
|
||||
ClineClass --> GlobalState
|
||||
ClineClass --> SecretsStorage
|
||||
ClineClass --> TaskStorage
|
||||
ClineClass --> CheckpointSystem
|
||||
ExtensionEntry --> WebviewProvider
|
||||
WebviewProvider --> Controller
|
||||
Controller --> Task
|
||||
Controller --> McpHub
|
||||
Task --> GlobalState
|
||||
Task --> SecretsStorage
|
||||
Task --> TaskStorage
|
||||
Task --> CheckpointSystem
|
||||
Task --> |API Requests| API Providers
|
||||
McpHub --> |Connects to| ExternalMcpServers
|
||||
Task --> |Uses| McpHub
|
||||
|
||||
%% Webview Data Flow
|
||||
WebviewApp --> ExtStateContext
|
||||
ExtStateContext --> ReactComponents
|
||||
|
||||
%% Bidirectional Communication
|
||||
ClineProvider <-->|postMessage| ExtStateContext
|
||||
WebviewProvider <-->|postMessage| ExtStateContext
|
||||
|
||||
style GlobalState fill:#f9f,stroke:#333,stroke-width:2px
|
||||
style SecretsStorage fill:#f9f,stroke:#333,stroke-width:2px
|
||||
style ExtStateContext fill:#bbf,stroke:#333,stroke-width:2px
|
||||
style ClineProvider fill:#bfb,stroke:#333,stroke-width:2px
|
||||
style WebviewProvider fill:#bfb,stroke:#333,stroke-width:2px
|
||||
style McpHub fill:#bfb,stroke:#333,stroke-width:2px
|
||||
style API Providers fill:#fdb,stroke:#333,stroke-width:2px
|
||||
```
|
||||
|
||||
## Definitions
|
||||
|
||||
- core extension: Anything inside the src folder starting with the Cline.ts file
|
||||
- core extension state: Managed by the ClineProvider class in src/core/webview/ClineProvider.ts, which serves as the single source of truth for the extension's state. It manages multiple types of persistent storage (global state, workspace state, and secrets), handles state distribution to both the core extension and webview components, and coordinates state across multiple extension instances. This includes managing API configurations, task history, settings, and MCP configurations.
|
||||
- webview: Anything inside the webview-ui. All the react or view's seen by the user and user interaction compone
|
||||
- webview state: Managed by ExtensionStateContext in webview-ui/src/context/ExtensionStateContext.tsx, which provides React components with access to the extension's state through a context provider pattern. It maintains local state for UI components, handles real-time updates through message events, manages partial message updates, and provides methods for state modifications. The context includes extension version, messages, task history, theme, API configurations, MCP servers, marketplace catalog, and workspace file paths. It synchronizes with the core extension through VSCode's message passing system and provides type-safe access to state through a custom hook (useExtensionState).
|
||||
- **Core Extension**: Anything inside the src folder, organized into modular components
|
||||
- **Core Extension State**: Managed by the Controller class in src/core/controller/index.ts, which serves as the single source of truth for the extension's state. It manages multiple types of persistent storage (global state, workspace state, and secrets), handles state distribution to both the core extension and webview components, and coordinates state across multiple extension instances. This includes managing API configurations, task history, settings, and MCP configurations.
|
||||
- **Webview**: Anything inside the webview-ui. All the react or view's seen by the user and user interaction components
|
||||
- **Webview State**: Managed by ExtensionStateContext in webview-ui/src/context/ExtensionStateContext.tsx, which provides React components with access to the extension's state through a context provider pattern. It maintains local state for UI components, handles real-time updates through message events, manages partial message updates, and provides methods for state modifications. The context includes extension version, messages, task history, theme, API configurations, MCP servers, marketplace catalog, and workspace file paths. It synchronizes with the core extension through VSCode's message passing system and provides type-safe access to state through a custom hook (useExtensionState).
|
||||
|
||||
### Core Extension Architecture
|
||||
|
||||
The core extension follows a clear hierarchical structure:
|
||||
|
||||
1. **WebviewProvider** (src/core/webview/index.ts): Manages the webview lifecycle and communication
|
||||
2. **Controller** (src/core/controller/index.ts): Handles webview messages and task management
|
||||
3. **Task** (src/core/task/index.ts): Executes API requests and tool operations
|
||||
|
||||
This architecture provides clear separation of concerns:
|
||||
- WebviewProvider focuses on VSCode webview integration
|
||||
- Controller manages state and coordinates tasks
|
||||
- Task handles the execution of AI requests and tool operations
|
||||
|
||||
### WebviewProvider Implementation
|
||||
|
||||
The WebviewProvider class in `src/core/webview/index.ts` is responsible for:
|
||||
|
||||
- Managing multiple active instances through a static set (`activeInstances`)
|
||||
- Handling webview lifecycle events (creation, visibility changes, disposal)
|
||||
- Implementing HTML content generation with proper CSP headers
|
||||
- Supporting Hot Module Replacement (HMR) for development
|
||||
- Setting up message listeners between the webview and extension
|
||||
|
||||
The WebviewProvider maintains a reference to the Controller and delegates message handling to it. It also handles the creation of both sidebar and tab panel webviews, allowing Cline to be used in different contexts within VSCode.
|
||||
|
||||
### Core Extension State
|
||||
|
||||
The `ClineProvider` class manages multiple types of persistent storage:
|
||||
The `Controller` class manages multiple types of persistent storage:
|
||||
|
||||
- **Global State:** Stored across all VSCode instances. Used for settings and data that should persist globally.
|
||||
- **Workspace State:** Specific to the current workspace. Used for task-specific data and settings.
|
||||
- **Secrets:** Secure storage for sensitive information like API keys.
|
||||
|
||||
The `ClineProvider` handles the distribution of state to both the core extension and webview components. It also coordinates state across multiple extension instances, ensuring consistency.
|
||||
The `Controller` handles the distribution of state to both the core extension and webview components. It also coordinates state across multiple extension instances, ensuring consistency.
|
||||
|
||||
State synchronization between instances is handled through:
|
||||
- File-based storage for task history and conversation data
|
||||
- VSCode's global state API for settings and configuration
|
||||
- Secrets storage for sensitive information
|
||||
- Event listeners for file changes and configuration updates
|
||||
|
||||
The Controller implements methods for:
|
||||
- Saving and loading task state
|
||||
- Managing API configurations
|
||||
- Handling user authentication
|
||||
- Coordinating MCP server connections
|
||||
- Managing task history and checkpoints
|
||||
|
||||
### Webview State
|
||||
|
||||
@@ -82,16 +140,65 @@ The `ExtensionStateContext` in `webview-ui/src/context/ExtensionStateContext.tsx
|
||||
|
||||
It synchronizes with the core extension through VSCode's message passing system and provides type-safe access to the state via a custom hook (`useExtensionState`).
|
||||
|
||||
## Core Extension (Cline.ts)
|
||||
The ExtensionStateContext handles:
|
||||
- Real-time updates through message events
|
||||
- Partial message updates for streaming content
|
||||
- State modifications through setter methods
|
||||
- Type-safe access to state through a custom hook
|
||||
|
||||
The Cline class is the heart of the extension, managing task execution, state persistence, and tool coordination. Each task runs in its own instance of the Cline class, ensuring isolation and proper state management.
|
||||
## API Provider System
|
||||
|
||||
Cline supports multiple AI providers through a modular API provider system. Each provider is implemented as a separate module in the `src/api/providers/` directory and follows a common interface.
|
||||
|
||||
### API Provider Architecture
|
||||
|
||||
The API system consists of:
|
||||
|
||||
1. **API Handlers**: Provider-specific implementations in `src/api/providers/`
|
||||
2. **API Transformers**: Stream transformation utilities in `src/api/transform/`
|
||||
3. **API Configuration**: User settings for API keys and endpoints
|
||||
4. **API Factory**: Builder function to create the appropriate handler
|
||||
|
||||
Key providers include:
|
||||
- **Anthropic**: Direct integration with Claude models
|
||||
- **OpenRouter**: Meta-provider supporting multiple model providers
|
||||
- **AWS Bedrock**: Integration with Amazon's AI services
|
||||
- **Gemini**: Google's AI models
|
||||
- **Ollama**: Local model hosting
|
||||
- **LM Studio**: Local model hosting
|
||||
- **VSCode LM**: VSCode's built-in language models
|
||||
|
||||
### API Configuration Management
|
||||
|
||||
API configurations are stored securely:
|
||||
- API keys are stored in VSCode's secrets storage
|
||||
- Model selections and non-sensitive settings are stored in global state
|
||||
- The Controller manages switching between providers and updating configurations
|
||||
|
||||
The system supports:
|
||||
- Secure storage of API keys
|
||||
- Model selection and configuration
|
||||
- Automatic retry and error handling
|
||||
- Token usage tracking and cost calculation
|
||||
- Context window management
|
||||
|
||||
### Plan/Act Mode API Configuration
|
||||
|
||||
Cline supports separate model configurations for Plan and Act modes:
|
||||
- Different models can be used for planning vs. execution
|
||||
- The system preserves model selections when switching modes
|
||||
- The Controller handles the transition between modes and updates the API configuration accordingly
|
||||
|
||||
## Task Execution System
|
||||
|
||||
The Task class is responsible for executing AI requests and tool operations. Each task runs in its own instance of the Task class, ensuring isolation and proper state management.
|
||||
|
||||
### Task Execution Loop
|
||||
|
||||
The core task execution loop follows this pattern:
|
||||
|
||||
```typescript
|
||||
class Cline {
|
||||
class Task {
|
||||
async initiateTaskLoop(userContent: UserContent, isNewTask: boolean) {
|
||||
while (!this.abort) {
|
||||
// 1. Make API request and stream response
|
||||
@@ -126,7 +233,7 @@ class Cline {
|
||||
The streaming system handles real-time updates and partial content:
|
||||
|
||||
```typescript
|
||||
class Cline {
|
||||
class Task {
|
||||
async presentAssistantMessage() {
|
||||
// Handle streaming locks to prevent race conditions
|
||||
if (this.presentAssistantMessageLocked) {
|
||||
@@ -161,7 +268,7 @@ class Cline {
|
||||
Tools follow a strict execution pattern:
|
||||
|
||||
```typescript
|
||||
class Cline {
|
||||
class Task {
|
||||
async executeToolWithApproval(block: ToolBlock) {
|
||||
// 1. Check auto-approval settings
|
||||
if (this.shouldAutoApproveTool(block.name)) {
|
||||
@@ -193,7 +300,7 @@ class Cline {
|
||||
The system includes robust error handling:
|
||||
|
||||
```typescript
|
||||
class Cline {
|
||||
class Task {
|
||||
async handleError(action: string, error: Error) {
|
||||
// 1. Check if task was abandoned
|
||||
if (this.abandoned) return
|
||||
@@ -216,23 +323,23 @@ class Cline {
|
||||
|
||||
### API Request & Token Management
|
||||
|
||||
The Cline class handles API requests with built-in retry, streaming, and token management:
|
||||
The Task class handles API requests with built-in retry, streaming, and token management:
|
||||
|
||||
```typescript
|
||||
class Cline {
|
||||
class Task {
|
||||
async *attemptApiRequest(previousApiReqIndex: number): ApiStream {
|
||||
// 1. Wait for MCP servers to connect
|
||||
await pWaitFor(() => this.providerRef.deref()?.mcpHub?.isConnecting !== true)
|
||||
await pWaitFor(() => this.controllerRef.deref()?.mcpHub?.isConnecting !== true)
|
||||
|
||||
// 2. Manage context window
|
||||
const previousRequest = this.clineMessages[previousApiReqIndex]
|
||||
if (previousRequest?.text) {
|
||||
const { tokensIn, tokensOut } = JSON.parse(previousRequest.text)
|
||||
const { tokensIn, tokensOut } = JSON.parse(previousRequest.text || "{}")
|
||||
const totalTokens = (tokensIn || 0) + (tokensOut || 0)
|
||||
|
||||
// Truncate conversation if approaching context limit
|
||||
if (totalTokens >= maxAllowedSize) {
|
||||
this.conversationHistoryDeletedRange = getNextTruncationRange(
|
||||
this.conversationHistoryDeletedRange = this.contextManager.getNextTruncationRange(
|
||||
this.apiConversationHistory,
|
||||
this.conversationHistoryDeletedRange,
|
||||
totalTokens / 2 > maxAllowedSize ? "quarter" : "half"
|
||||
@@ -299,16 +406,32 @@ Key features:
|
||||
- Cost calculation
|
||||
- Cache hit monitoring
|
||||
|
||||
### Context Management System
|
||||
|
||||
The Context Management System handles conversation history truncation to prevent context window overflow errors. Implemented in the `ContextManager` class, it ensures long-running conversations remain within model context limits while preserving critical context.
|
||||
|
||||
Key features:
|
||||
|
||||
1. **Model-Aware Sizing**: Dynamically adjusts based on different model context windows (64K for DeepSeek, 128K for most models, 200K for Claude).
|
||||
|
||||
2. **Proactive Truncation**: Monitors token usage and preemptively truncates conversations when approaching limits, maintaining buffers of 27K-40K tokens depending on the model.
|
||||
|
||||
3. **Intelligent Preservation**: Always preserves the original task message and maintains the user-assistant conversation structure when truncating.
|
||||
|
||||
4. **Adaptive Strategies**: Uses different truncation strategies based on context pressure - removing half of the conversation for moderate pressure or three-quarters for severe pressure.
|
||||
|
||||
5. **Error Recovery**: Includes specialized detection for context window errors from different providers with automatic retry and more aggressive truncation when needed.
|
||||
|
||||
### Task State & Resumption
|
||||
|
||||
The Cline class provides robust task state management and resumption capabilities:
|
||||
The Task class provides robust task state management and resumption capabilities:
|
||||
|
||||
```typescript
|
||||
class Cline {
|
||||
class Task {
|
||||
async resumeTaskFromHistory() {
|
||||
// 1. Load saved state
|
||||
this.clineMessages = await this.getSavedClineMessages()
|
||||
this.apiConversationHistory = await this.getSavedApiConversationHistory()
|
||||
this.clineMessages = await getSavedClineMessages(this.getContext(), this.taskId)
|
||||
this.apiConversationHistory = await getSavedApiConversationHistory(this.getContext(), this.taskId)
|
||||
|
||||
// 2. Handle interrupted tool executions
|
||||
const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1]
|
||||
@@ -338,14 +461,14 @@ class Cline {
|
||||
|
||||
private async saveTaskState() {
|
||||
// Save conversation history
|
||||
await this.saveApiConversationHistory()
|
||||
await this.saveClineMessages()
|
||||
await saveApiConversationHistory(this.getContext(), this.taskId, this.apiConversationHistory)
|
||||
await saveClineMessages(this.getContext(), this.taskId, this.clineMessages)
|
||||
|
||||
// Create checkpoint
|
||||
const commitHash = await this.checkpointTracker?.commit()
|
||||
|
||||
// Update task history
|
||||
await this.providerRef.deref()?.updateTaskHistory({
|
||||
await this.controllerRef.deref()?.updateTaskHistory({
|
||||
id: this.taskId,
|
||||
ts: lastMessage.ts,
|
||||
task: taskMessage.text,
|
||||
@@ -381,11 +504,54 @@ Key aspects of task state management:
|
||||
- Resources are cleaned up properly
|
||||
- User is notified of state changes
|
||||
|
||||
## Plan/Act Mode System
|
||||
|
||||
Cline implements a dual-mode system that separates planning from execution:
|
||||
|
||||
### Mode Architecture
|
||||
|
||||
The Plan/Act mode system consists of:
|
||||
|
||||
1. **Mode State**: Stored in `chatSettings.mode` in the Controller's state
|
||||
2. **Mode Switching**: Handled by `togglePlanActModeWithChatSettings` in the Controller
|
||||
3. **Mode-specific Models**: Optional configuration to use different models for each mode
|
||||
4. **Mode-specific Prompting**: Different system prompts for planning vs. execution
|
||||
|
||||
### Mode Switching Process
|
||||
|
||||
When switching between modes:
|
||||
|
||||
1. The current model configuration is saved to mode-specific state
|
||||
2. The previous mode's model configuration is restored
|
||||
3. The Task instance is updated with the new mode
|
||||
4. The webview is notified of the mode change
|
||||
5. Telemetry events are captured for analytics
|
||||
|
||||
### Plan Mode
|
||||
|
||||
Plan mode is designed for:
|
||||
- Information gathering and context building
|
||||
- Asking clarifying questions
|
||||
- Creating detailed execution plans
|
||||
- Discussing approaches with the user
|
||||
|
||||
In Plan mode, the AI uses the `plan_mode_respond` tool to engage in conversational planning without executing actions.
|
||||
|
||||
### Act Mode
|
||||
|
||||
Act mode is designed for:
|
||||
- Executing the planned actions
|
||||
- Using tools to modify files, run commands, etc.
|
||||
- Implementing the solution
|
||||
- Providing results and completion feedback
|
||||
|
||||
In Act mode, the AI has access to all tools except `plan_mode_respond` and focuses on implementation rather than discussion.
|
||||
|
||||
## Data Flow & State Management
|
||||
|
||||
### Core Extension Role
|
||||
|
||||
The core extension (ClineProvider) acts as the single source of truth for all persistent state. It:
|
||||
The Controller acts as the single source of truth for all persistent state. It:
|
||||
- Manages VSCode global state and secrets storage
|
||||
- Coordinates state updates between components
|
||||
- Ensures state consistency across webview reloads
|
||||
@@ -394,10 +560,10 @@ The core extension (ClineProvider) acts as the single source of truth for all pe
|
||||
|
||||
### Terminal Management
|
||||
|
||||
The Cline class manages terminal instances and command execution:
|
||||
The Task class manages terminal instances and command execution:
|
||||
|
||||
```typescript
|
||||
class Cline {
|
||||
class Task {
|
||||
async executeCommandTool(command: string): Promise<[boolean, ToolResponse]> {
|
||||
// 1. Get or create terminal
|
||||
const terminalInfo = await this.terminalManager.getOrCreateTerminal(cwd)
|
||||
@@ -453,10 +619,10 @@ Key features:
|
||||
|
||||
### Browser Session Management
|
||||
|
||||
The Cline class handles browser automation through Puppeteer:
|
||||
The Task class handles browser automation through Puppeteer:
|
||||
|
||||
```typescript
|
||||
class Cline {
|
||||
class Task {
|
||||
async executeBrowserAction(action: BrowserAction): Promise<BrowserActionResult> {
|
||||
switch (action) {
|
||||
case "launch":
|
||||
@@ -493,13 +659,93 @@ Key aspects:
|
||||
- Screenshot capture
|
||||
- Error recovery
|
||||
|
||||
## MCP (Model Context Protocol) Integration
|
||||
|
||||
### MCP Architecture
|
||||
|
||||
The MCP system consists of:
|
||||
|
||||
1. **McpHub Class**: Central manager in `src/services/mcp/McpHub.ts`
|
||||
2. **MCP Connections**: Manages connections to external MCP servers
|
||||
3. **MCP Settings**: Configuration stored in a JSON file
|
||||
4. **MCP Marketplace**: Online catalog of available MCP servers
|
||||
5. **MCP Tools & Resources**: Capabilities exposed by connected servers
|
||||
|
||||
The McpHub class:
|
||||
- Manages the lifecycle of MCP server connections
|
||||
- Handles server configuration through a settings file
|
||||
- Provides methods for calling tools and accessing resources
|
||||
- Implements auto-approval settings for MCP tools
|
||||
- Monitors server health and handles reconnection
|
||||
|
||||
### MCP Server Types
|
||||
|
||||
Cline supports two types of MCP server connections:
|
||||
- **Stdio**: Command-line based servers that communicate via standard I/O
|
||||
- **SSE**: HTTP-based servers that communicate via Server-Sent Events
|
||||
|
||||
### MCP Server Management
|
||||
|
||||
The McpHub class provides methods for:
|
||||
- Discovering and connecting to MCP servers
|
||||
- Monitoring server health and status
|
||||
- Restarting servers when needed
|
||||
- Managing server configurations
|
||||
- Setting timeouts and auto-approval rules
|
||||
|
||||
### MCP Tool Integration
|
||||
|
||||
MCP tools are integrated into the Task execution system:
|
||||
- Tools are discovered and registered at connection time
|
||||
- The Task class can call MCP tools through the McpHub
|
||||
- Tool results are streamed back to the AI
|
||||
- Auto-approval settings can be configured per tool
|
||||
|
||||
### MCP Marketplace
|
||||
|
||||
The MCP Marketplace provides:
|
||||
- A catalog of available MCP servers
|
||||
- One-click installation
|
||||
- README previews
|
||||
- Server status monitoring
|
||||
|
||||
The Controller class manages MCP servers through the McpHub service:
|
||||
|
||||
```typescript
|
||||
class Controller {
|
||||
mcpHub?: McpHub
|
||||
|
||||
constructor(context: vscode.ExtensionContext, outputChannel: vscode.OutputChannel, webviewProvider: WebviewProvider) {
|
||||
this.mcpHub = new McpHub(this)
|
||||
}
|
||||
|
||||
async downloadMcp(mcpId: string) {
|
||||
// Fetch server details from marketplace
|
||||
const response = await axios.post<McpDownloadResponse>(
|
||||
"https://api.cline.bot/v1/mcp/download",
|
||||
{ mcpId },
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
timeout: 10000,
|
||||
}
|
||||
)
|
||||
|
||||
// Create task with context from README
|
||||
const task = `Set up the MCP server from ${mcpDetails.githubUrl}...`
|
||||
|
||||
// Initialize task and show chat view
|
||||
await this.initClineWithTask(task)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
This guide provides a comprehensive overview of the Cline extension architecture, with special focus on state management, data persistence, and code organization. Following these patterns ensures robust feature implementation with proper state handling across the extension's components.
|
||||
|
||||
Remember:
|
||||
- Always persist important state in the extension
|
||||
- The core extension exists in the src/ folder
|
||||
- The core extension follows a WebviewProvider -> Controller -> Task flow
|
||||
- Use proper typing for all state and messages
|
||||
- Handle errors and edge cases
|
||||
- Test state persistence across webview reloads
|
||||
@@ -31,6 +31,14 @@ body:
|
||||
label: Relevant API REQUEST output
|
||||
description: Please copy and paste any relevant output. This will be automatically formatted into code, so no need for backticks.
|
||||
render: shell
|
||||
- type: input
|
||||
id: provider-model
|
||||
attributes:
|
||||
label: Provider/Model
|
||||
description: What provider and model were you using when the issue occurred?
|
||||
placeholder: "e.g., cline:anthropic/claude-3.7-sonnet, gemini:gemini-2.5-pro-exp-03-25"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: operating-system
|
||||
attributes:
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
- [ ] 🐛 Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] ✨ New feature (non-breaking change which adds functionality)
|
||||
- [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] ♻️ Refactor Changes
|
||||
- [ ] 💅 Cosmetic Changes
|
||||
- [ ] 📚 Documentation update
|
||||
- [ ] 🏃 Workflow Changes
|
||||
|
||||
### Pre-flight Checklist
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
Coverage utility package for GitHub Actions workflows.
|
||||
This package handles extracting coverage percentages, comparing them, and generating PR comments.
|
||||
"""
|
||||
|
||||
# Import external dependencies
|
||||
import requests
|
||||
|
||||
# Import main function for CLI usage
|
||||
from .__main__ import main
|
||||
|
||||
# Import functions from extraction module
|
||||
from .extraction import extract_coverage, compare_coverage, run_coverage, set_verbose
|
||||
|
||||
# Import functions from github_api module
|
||||
from .github_api import generate_comment, post_comment, set_github_output
|
||||
|
||||
# Import functions from workflow module
|
||||
from .workflow import process_coverage_workflow
|
||||
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Main module.
|
||||
This module provides the CLI interface for the coverage utility script.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import argparse
|
||||
|
||||
from .extraction import extract_coverage, compare_coverage, run_coverage, set_verbose
|
||||
from .github_api import generate_comment, post_comment, set_github_output
|
||||
from .workflow import process_coverage_workflow
|
||||
from .util import log
|
||||
|
||||
def setup_verbose_mode(args):
|
||||
"""
|
||||
Set up verbose mode based on command line arguments.
|
||||
|
||||
Args:
|
||||
args: Parsed command line arguments
|
||||
"""
|
||||
if getattr(args, 'verbose', False):
|
||||
set_verbose(True)
|
||||
log("Verbose mode enabled")
|
||||
|
||||
def main():
|
||||
# Create parent parser with common arguments
|
||||
parent_parser = argparse.ArgumentParser(add_help=False)
|
||||
parent_parser.add_argument('-v', '--verbose', action='store_true', help='Enable verbose output')
|
||||
|
||||
# Create main parser that inherits common arguments
|
||||
parser = argparse.ArgumentParser(description='Coverage utility script for GitHub Actions workflows', parents=[parent_parser])
|
||||
subparsers = parser.add_subparsers(dest='command', help='Command to run')
|
||||
|
||||
# extract-coverage command - used directly in workflow
|
||||
extract_parser = subparsers.add_parser('extract-coverage', help='Extract coverage percentage from a file', parents=[parent_parser])
|
||||
extract_parser.add_argument('file_path', help='Path to the coverage report file')
|
||||
extract_parser.add_argument('--type', choices=['extension', 'webview'], default='extension',
|
||||
help='Type of coverage report')
|
||||
extract_parser.add_argument('--github-output', action='store_true', help='Output in GitHub Actions format')
|
||||
|
||||
# compare-coverage command - used by process-workflow
|
||||
compare_parser = subparsers.add_parser('compare-coverage', help='Compare coverage percentages', parents=[parent_parser])
|
||||
compare_parser.add_argument('base_cov', help='Base branch coverage percentage')
|
||||
compare_parser.add_argument('pr_cov', help='PR branch coverage percentage')
|
||||
compare_parser.add_argument('--output-prefix', default='', help='Prefix for GitHub Actions output variables')
|
||||
compare_parser.add_argument('--github-output', action='store_true', help='Output in GitHub Actions format')
|
||||
|
||||
# generate-comment command - used by process-workflow
|
||||
comment_parser = subparsers.add_parser('generate-comment', help='Generate PR comment with coverage comparison', parents=[parent_parser])
|
||||
comment_parser.add_argument('base_ext_cov', help='Base branch extension coverage')
|
||||
comment_parser.add_argument('pr_ext_cov', help='PR branch extension coverage')
|
||||
comment_parser.add_argument('ext_decreased', help='Whether extension coverage decreased (true/false)')
|
||||
comment_parser.add_argument('ext_diff', help='Extension coverage difference')
|
||||
comment_parser.add_argument('base_web_cov', help='Base branch webview coverage')
|
||||
comment_parser.add_argument('pr_web_cov', help='PR branch webview coverage')
|
||||
comment_parser.add_argument('web_decreased', help='Whether webview coverage decreased (true/false)')
|
||||
comment_parser.add_argument('web_diff', help='Webview coverage difference')
|
||||
|
||||
# post-comment command - used by process-workflow
|
||||
post_parser = subparsers.add_parser('post-comment', help='Post a comment to a GitHub PR', parents=[parent_parser])
|
||||
post_parser.add_argument('comment_path', help='Path to the file containing the comment text')
|
||||
post_parser.add_argument('pr_number', help='PR number')
|
||||
post_parser.add_argument('repo', help='Repository in the format "owner/repo"')
|
||||
post_parser.add_argument('--token', help='GitHub token')
|
||||
|
||||
# run-coverage command - used by process-workflow
|
||||
run_parser = subparsers.add_parser('run-coverage', help='Run a coverage command and extract the coverage percentage', parents=[parent_parser])
|
||||
run_parser.add_argument('coverage_cmd', help='Command to run')
|
||||
run_parser.add_argument('output_file', help='File to save the output to')
|
||||
run_parser.add_argument('--type', choices=['extension', 'webview'], default='extension',
|
||||
help='Type of coverage report')
|
||||
run_parser.add_argument('--github-output', action='store_true', help='Output in GitHub Actions format')
|
||||
|
||||
# process-workflow command - used directly in workflow
|
||||
workflow_parser = subparsers.add_parser('process-workflow', help='Process the entire coverage workflow', parents=[parent_parser])
|
||||
workflow_parser.add_argument('--base-branch', required=True, help='Base branch name')
|
||||
workflow_parser.add_argument('--pr-number', help='PR number')
|
||||
workflow_parser.add_argument('--repo', help='Repository in the format "owner/repo"')
|
||||
workflow_parser.add_argument('--token', help='GitHub token')
|
||||
|
||||
# set-github-output command - used by process-workflow
|
||||
output_parser = subparsers.add_parser('set-github-output', help='Set GitHub Actions output variable', parents=[parent_parser])
|
||||
output_parser.add_argument('name', help='Output variable name')
|
||||
output_parser.add_argument('value', help='Output variable value')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Set up verbose mode
|
||||
setup_verbose_mode(args)
|
||||
|
||||
if args.command == 'extract-coverage':
|
||||
log(f"Extracting coverage from file: {args.file_path} (type: {args.type})")
|
||||
coverage_pct = extract_coverage(args.file_path, args.type)
|
||||
if args.github_output:
|
||||
set_github_output(f"{args.type}_coverage", coverage_pct)
|
||||
else:
|
||||
log(f"Coverage: {coverage_pct}%")
|
||||
|
||||
elif args.command == 'compare-coverage':
|
||||
log(f"Comparing coverage: base={args.base_cov}%, PR={args.pr_cov}%")
|
||||
decreased, diff = compare_coverage(args.base_cov, args.pr_cov)
|
||||
if args.github_output:
|
||||
prefix = args.output_prefix
|
||||
set_github_output(f"{prefix}decreased", str(decreased).lower())
|
||||
set_github_output(f"{prefix}diff", diff)
|
||||
log(f"Coverage difference: {diff}%")
|
||||
log(f"Coverage decreased: {decreased}")
|
||||
else:
|
||||
log(f"decreased={str(decreased).lower()}")
|
||||
log(f"diff={diff}")
|
||||
|
||||
elif args.command == 'generate-comment':
|
||||
log("Generating coverage comparison comment")
|
||||
comment = generate_comment(
|
||||
args.base_ext_cov, args.pr_ext_cov, args.ext_decreased, args.ext_diff,
|
||||
args.base_web_cov, args.pr_web_cov, args.web_decreased, args.web_diff
|
||||
)
|
||||
# Output the comment to stdout
|
||||
log(comment)
|
||||
|
||||
elif args.command == 'post-comment':
|
||||
log(f"Posting comment from {args.comment_path} to PR #{args.pr_number} in {args.repo}")
|
||||
post_comment(args.comment_path, args.pr_number, args.repo, args.token)
|
||||
|
||||
elif args.command == 'run-coverage':
|
||||
log(f"Running coverage command: {args.coverage_cmd}")
|
||||
log(f"Output file: {args.output_file}")
|
||||
log(f"Coverage type: {args.type}")
|
||||
coverage_pct = run_coverage(args.coverage_cmd, args.output_file, args.type)
|
||||
if args.github_output:
|
||||
set_github_output(f"{args.type}_coverage", coverage_pct)
|
||||
else:
|
||||
log(f"Coverage: {coverage_pct}%")
|
||||
|
||||
elif args.command == 'process-workflow':
|
||||
log("Processing coverage workflow")
|
||||
log(f"Base branch: {args.base_branch}")
|
||||
if args.pr_number:
|
||||
log(f"PR number: {args.pr_number}")
|
||||
if args.repo:
|
||||
log(f"Repository: {args.repo}")
|
||||
process_coverage_workflow(args)
|
||||
|
||||
elif args.command == 'set-github-output':
|
||||
log(f"Setting GitHub output: {args.name}={args.value}")
|
||||
set_github_output(args.name, args.value)
|
||||
|
||||
else:
|
||||
log("No command specified")
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,265 @@
|
||||
"""
|
||||
Coverage extraction module.
|
||||
This module handles extracting coverage percentages from coverage report files.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import shlex
|
||||
import subprocess
|
||||
import traceback
|
||||
from .util import log, file_exists, get_file_size, list_directory, is_safe_command, run_command
|
||||
|
||||
# Global verbose flag
|
||||
verbose = False
|
||||
|
||||
def set_verbose(value):
|
||||
"""Set the global verbose flag."""
|
||||
global verbose
|
||||
verbose = value
|
||||
|
||||
def print_debug_output(content, coverage_type):
|
||||
"""
|
||||
Print debug information about the coverage output.
|
||||
|
||||
Args:
|
||||
content: The content of the coverage file
|
||||
coverage_type: Type of coverage report (extension or webview)
|
||||
"""
|
||||
if not verbose:
|
||||
return
|
||||
|
||||
# Extract and print only the coverage summary section
|
||||
if coverage_type == "extension":
|
||||
# Look for the coverage summary section
|
||||
summary_match = re.search(r'=============================== Coverage summary ===============================\n(.*?)\n=+', content, re.DOTALL)
|
||||
if summary_match:
|
||||
sys.stdout.write("\n##[group]EXTENSION COVERAGE SUMMARY\n")
|
||||
sys.stdout.write("=============================== Coverage summary ===============================\n")
|
||||
sys.stdout.write(summary_match.group(1) + "\n")
|
||||
sys.stdout.write("================================================================================\n")
|
||||
sys.stdout.write("##[endgroup]\n")
|
||||
sys.stdout.flush()
|
||||
else:
|
||||
sys.stdout.write("\n##[warning]No coverage summary found in extension coverage file\n")
|
||||
sys.stdout.flush()
|
||||
else: # webview
|
||||
# Look for the coverage table - specifically the "All files" row
|
||||
table_match = re.search(r'% Coverage report from v8.*?-+\|.*?\n.*?\n(All files.*?)(?:\n[^\n]*\|)', content, re.DOTALL)
|
||||
if table_match:
|
||||
sys.stdout.write("\n##[group]WEBVIEW COVERAGE SUMMARY\n")
|
||||
sys.stdout.write("% Coverage report from v8\n")
|
||||
sys.stdout.write("-------------------|---------|----------|---------|---------|-------------------\n")
|
||||
sys.stdout.write("File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s \n")
|
||||
sys.stdout.write("-------------------|---------|----------|---------|---------|-------------------\n")
|
||||
sys.stdout.write(table_match.group(1) + "\n")
|
||||
sys.stdout.write("-------------------|---------|----------|---------|---------|-------------------\n")
|
||||
sys.stdout.write("##[endgroup]\n")
|
||||
sys.stdout.flush()
|
||||
else:
|
||||
sys.stdout.write("\n##[warning]No coverage table found in webview coverage file\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
def extract_coverage(file_path, coverage_type="extension"):
|
||||
"""
|
||||
Extract coverage percentage from a coverage report file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the coverage report file
|
||||
coverage_type: Type of coverage report (extension or webview)
|
||||
|
||||
Returns:
|
||||
Coverage percentage as a float
|
||||
"""
|
||||
|
||||
# Always print file path for debugging
|
||||
log(f"Checking coverage file: {file_path}")
|
||||
|
||||
# Check if file exists and get its size
|
||||
if not file_exists(file_path):
|
||||
sys.stdout.write(f"\n##[error]File {file_path} does not exist\n")
|
||||
sys.stdout.flush()
|
||||
log(f"Error: File {file_path} does not exist")
|
||||
|
||||
# Check if the directory exists
|
||||
dir_path = os.path.dirname(file_path)
|
||||
if not os.path.exists(dir_path):
|
||||
sys.stdout.write(f"\n##[error]Directory {dir_path} does not exist\n")
|
||||
sys.stdout.flush()
|
||||
log(f"Error: Directory {dir_path} does not exist")
|
||||
else:
|
||||
# List directory contents for debugging
|
||||
log(f"Directory {dir_path} exists, listing contents:")
|
||||
try:
|
||||
dir_contents = list_directory(dir_path)
|
||||
for name, size in dir_contents:
|
||||
log(f" {name} - {size}")
|
||||
sys.stdout.write(f" {name} - {size}\n")
|
||||
sys.stdout.flush()
|
||||
except Exception as e:
|
||||
log(f"Error listing directory: {e}")
|
||||
|
||||
return 0.0
|
||||
|
||||
file_size = get_file_size(file_path)
|
||||
log(f"File size: {file_size} bytes")
|
||||
sys.stdout.write(f"\n##[info]Coverage file {file_path} exists, size: {file_size} bytes\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
if file_size == 0:
|
||||
sys.stdout.write(f"\n##[warning]File {file_path} is empty\n")
|
||||
sys.stdout.flush()
|
||||
log(f"Warning: File {file_path} is empty")
|
||||
return 0.0
|
||||
|
||||
# List directory contents for debugging
|
||||
dir_path = os.path.dirname(file_path)
|
||||
log(f"Directory contents of {dir_path}:")
|
||||
try:
|
||||
dir_contents = list_directory(dir_path)
|
||||
for name, size in dir_contents:
|
||||
log(f" {name} - {size}")
|
||||
except Exception as e:
|
||||
log(f"Error listing directory: {e}")
|
||||
|
||||
with open(file_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Print debug information if verbose
|
||||
print_debug_output(content, coverage_type)
|
||||
|
||||
# Extract coverage percentage based on coverage type
|
||||
if coverage_type == "extension":
|
||||
# Extract the percentage from the "Lines" row in the coverage summary
|
||||
# Pattern: Lines : xx.xx% ( xxxxxxx/xxxxxxx )
|
||||
lines_match = re.search(r'Lines\s*:\s*(\d+\.\d+)%', content)
|
||||
if lines_match:
|
||||
coverage_pct = float(lines_match.group(1))
|
||||
if verbose:
|
||||
sys.stdout.write(f"Pattern matched (Lines percentage): {coverage_pct}\n")
|
||||
sys.stdout.flush()
|
||||
return coverage_pct
|
||||
else:
|
||||
# No coverage data found, log full content for debugging
|
||||
log("No coverage data found. Full file content:")
|
||||
log("=== Full file content ===")
|
||||
log(content)
|
||||
log("=== End file content ===")
|
||||
else: # webview
|
||||
# Extract the percentage from the "% Lines" column in the "All files" row
|
||||
# Pattern: All files | xx.xx | xx.xx | xx.xx | xx.xx |
|
||||
all_files_match = re.search(r'All files\s+\|\s+\d+\.\d+\s+\|\s+\d+\.\d+\s+\|\s+\d+\.\d+\s+\|\s+(\d+\.\d+)', content)
|
||||
if all_files_match:
|
||||
coverage_pct = float(all_files_match.group(1))
|
||||
if verbose:
|
||||
sys.stdout.write(f"Pattern matched (All files % Lines): {coverage_pct}\n")
|
||||
sys.stdout.flush()
|
||||
return coverage_pct
|
||||
else:
|
||||
# No coverage data found, log full content for debugging
|
||||
log("No coverage data found. Full file content:")
|
||||
log("=== Full file content ===")
|
||||
log(content)
|
||||
log("=== End file content ===")
|
||||
|
||||
# If no match found, return 0.0
|
||||
return 0.0
|
||||
|
||||
def compare_coverage(base_cov, pr_cov):
|
||||
"""
|
||||
Compare coverage percentages between base and PR branches.
|
||||
|
||||
Args:
|
||||
base_cov: Base branch coverage percentage
|
||||
pr_cov: PR branch coverage percentage
|
||||
|
||||
Returns:
|
||||
Tuple of (decreased, diff)
|
||||
"""
|
||||
try:
|
||||
base_cov = float(base_cov)
|
||||
pr_cov = float(pr_cov)
|
||||
except ValueError:
|
||||
sys.stdout.write(f"Error: Invalid coverage values - base: {base_cov}, PR: {pr_cov}\n")
|
||||
sys.stdout.flush()
|
||||
return False, 0
|
||||
|
||||
diff = pr_cov - base_cov
|
||||
decreased = diff < 0
|
||||
|
||||
return decreased, abs(diff)
|
||||
|
||||
def run_coverage(command, output_file, coverage_type="extension"):
|
||||
"""
|
||||
Run a coverage command and extract the coverage percentage.
|
||||
|
||||
Args:
|
||||
command: Command to run
|
||||
output_file: File to save the output to
|
||||
coverage_type: Type of coverage report (extension or webview)
|
||||
|
||||
Returns:
|
||||
Coverage percentage as a float
|
||||
|
||||
Raises:
|
||||
SystemExit: If the output file is not created or is empty
|
||||
"""
|
||||
|
||||
try:
|
||||
# Run the command and capture output
|
||||
if not is_safe_command(command):
|
||||
error_msg = f"ERROR: Unsafe command detected: {command}"
|
||||
log(error_msg)
|
||||
sys.stdout.write(f"\n##[error]{error_msg}\n")
|
||||
sys.stdout.flush()
|
||||
sys.exit(1)
|
||||
|
||||
# Run command using safe execution from util
|
||||
returncode, stdout, stderr = run_command(command)
|
||||
|
||||
# Log command result
|
||||
log(f"Command exit code: {returncode}")
|
||||
log(f"Command stdout length: {len(stdout)} bytes")
|
||||
log(f"Command stderr length: {len(stderr)} bytes")
|
||||
|
||||
# Save output to file
|
||||
log(f"Saving command output to {output_file}")
|
||||
with open(output_file, 'w') as f:
|
||||
f.write(stdout)
|
||||
if stderr:
|
||||
f.write("\n\n=== STDERR ===\n")
|
||||
f.write(stderr)
|
||||
|
||||
# Verify file was created and has content
|
||||
if not file_exists(output_file):
|
||||
error_msg = f"ERROR: Output file {output_file} was not created"
|
||||
log(error_msg)
|
||||
sys.stdout.write(f"\n##[error]{error_msg}\n")
|
||||
sys.stdout.flush()
|
||||
sys.exit(1) # Exit with error code to fail the workflow
|
||||
|
||||
file_size = get_file_size(output_file)
|
||||
if file_size == 0:
|
||||
error_msg = f"ERROR: Output file {output_file} is empty"
|
||||
log(error_msg)
|
||||
sys.stdout.write(f"\n##[error]{error_msg}\n")
|
||||
sys.stdout.flush()
|
||||
sys.exit(1) # Exit with error code to fail the workflow
|
||||
|
||||
log(f"Output file size: {file_size} bytes")
|
||||
|
||||
# Extract coverage percentage
|
||||
coverage_pct = extract_coverage(output_file, coverage_type)
|
||||
|
||||
log(f"{coverage_type.capitalize()} coverage: {coverage_pct}%")
|
||||
return coverage_pct
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error running coverage command: {e}"
|
||||
log(error_msg)
|
||||
sys.stdout.write(f"\n##[error]{error_msg}\n")
|
||||
sys.stdout.flush()
|
||||
# Print stack trace for debugging
|
||||
log(traceback.format_exc())
|
||||
sys.exit(1) # Exit with error code to fail the workflow
|
||||
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
GitHub API module.
|
||||
This module handles interactions with the GitHub API for posting comments to PRs.
|
||||
"""
|
||||
|
||||
import os
|
||||
import requests
|
||||
from .util import log, file_exists
|
||||
|
||||
def generate_comment(base_ext_cov, pr_ext_cov, ext_decreased, ext_diff,
|
||||
base_web_cov, pr_web_cov, web_decreased, web_diff):
|
||||
"""
|
||||
Generate a PR comment with coverage comparison.
|
||||
|
||||
Args:
|
||||
base_ext_cov: Base branch extension coverage
|
||||
pr_ext_cov: PR branch extension coverage
|
||||
ext_decreased: Whether extension coverage decreased
|
||||
ext_diff: Extension coverage difference
|
||||
base_web_cov: Base branch webview coverage
|
||||
pr_web_cov: PR branch webview coverage
|
||||
web_decreased: Whether webview coverage decreased
|
||||
web_diff: Webview coverage difference
|
||||
|
||||
Returns:
|
||||
Comment text
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
# Convert string inputs to appropriate types
|
||||
try:
|
||||
base_ext_cov = float(base_ext_cov)
|
||||
pr_ext_cov = float(pr_ext_cov)
|
||||
# Handle ext_decreased as either string or boolean
|
||||
if isinstance(ext_decreased, str):
|
||||
ext_decreased = ext_decreased.lower() == 'true'
|
||||
else:
|
||||
ext_decreased = bool(ext_decreased)
|
||||
ext_diff = float(ext_diff)
|
||||
base_web_cov = float(base_web_cov)
|
||||
pr_web_cov = float(pr_web_cov)
|
||||
# Handle web_decreased as either string or boolean
|
||||
if isinstance(web_decreased, str):
|
||||
web_decreased = web_decreased.lower() == 'true'
|
||||
else:
|
||||
web_decreased = bool(web_decreased)
|
||||
web_diff = float(web_diff)
|
||||
except ValueError as e:
|
||||
log(f"Error converting input values: {e}")
|
||||
return ""
|
||||
|
||||
# Add a unique identifier to find this comment later
|
||||
comment = '<!-- COVERAGE_REPORT -->\n'
|
||||
comment += '## Coverage Report\n\n'
|
||||
|
||||
# Extension coverage
|
||||
comment += '### Extension Coverage\n\n'
|
||||
comment += f'Base branch: {base_ext_cov:.0f}%\n\n'
|
||||
comment += f'PR branch: {pr_ext_cov:.0f}%\n\n'
|
||||
|
||||
if ext_decreased:
|
||||
comment += f'⚠️ **Warning: Coverage decreased by {ext_diff:.2f}%**\n\n'
|
||||
comment += 'Consider adding tests to cover your changes.\n\n'
|
||||
else:
|
||||
comment += '✅ Coverage increased or remained the same\n\n'
|
||||
|
||||
# Webview coverage
|
||||
comment += '### Webview Coverage\n\n'
|
||||
comment += f'Base branch: {base_web_cov:.0f}%\n\n'
|
||||
comment += f'PR branch: {pr_web_cov:.0f}%\n\n'
|
||||
|
||||
if web_decreased:
|
||||
comment += f'⚠️ **Warning: Coverage decreased by {web_diff:.2f}%**\n\n'
|
||||
comment += 'Consider adding tests to cover your changes.\n\n'
|
||||
else:
|
||||
comment += '✅ Coverage increased or remained the same\n\n'
|
||||
|
||||
# Overall assessment
|
||||
comment += '### Overall Assessment\n\n'
|
||||
if ext_decreased or web_decreased:
|
||||
comment += '⚠️ **Test coverage has decreased in this PR**\n\n'
|
||||
comment += 'Please consider adding tests to maintain or improve coverage.\n\n'
|
||||
else:
|
||||
comment += '✅ **Test coverage has been maintained or improved**\n\n'
|
||||
|
||||
# Add timestamp
|
||||
comment += f'\n\n<sub>Last updated: {datetime.now().isoformat()}</sub>'
|
||||
|
||||
return comment
|
||||
|
||||
def post_comment(comment_path, pr_number, repo, token=None):
|
||||
"""
|
||||
Post a comment to a GitHub PR.
|
||||
|
||||
Args:
|
||||
comment_path: Path to the file containing the comment text
|
||||
pr_number: PR number
|
||||
repo: Repository in the format "owner/repo"
|
||||
token: GitHub token
|
||||
"""
|
||||
if not file_exists(comment_path):
|
||||
log(f"Error: Comment file {comment_path} does not exist")
|
||||
return
|
||||
|
||||
with open(comment_path, 'r') as f:
|
||||
comment_body = f.read()
|
||||
|
||||
if not token:
|
||||
token = os.environ.get('GITHUB_TOKEN')
|
||||
if not token:
|
||||
log("Error: GitHub token not provided")
|
||||
return
|
||||
|
||||
# Find existing comment
|
||||
headers = {
|
||||
'Authorization': f'token {token}',
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
}
|
||||
|
||||
# Get all comments
|
||||
comments_url = f'https://api.github.com/repos/{repo}/issues/{pr_number}/comments'
|
||||
log(f"Getting comments from: {comments_url}")
|
||||
response = requests.get(comments_url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
log(f"Error getting comments: {response.status_code} - {response.text}")
|
||||
return
|
||||
|
||||
comments = response.json()
|
||||
log(f"Found {len(comments)} existing comments")
|
||||
|
||||
# Find comment with our identifier
|
||||
comment_id = None
|
||||
for comment in comments:
|
||||
if '<!-- COVERAGE_REPORT -->' in comment['body']:
|
||||
comment_id = comment['id']
|
||||
log(f"Found existing coverage report comment with ID: {comment_id}")
|
||||
break
|
||||
|
||||
if comment_id:
|
||||
# Update existing comment
|
||||
update_url = f'https://api.github.com/repos/{repo}/issues/comments/{comment_id}'
|
||||
log(f"Updating existing comment at: {update_url}")
|
||||
response = requests.patch(update_url, headers=headers, json={'body': comment_body})
|
||||
|
||||
if response.status_code == 200:
|
||||
log(f"Successfully updated existing comment: {comment_id}")
|
||||
else:
|
||||
log(f"Error updating comment: {response.status_code} - {response.text}")
|
||||
else:
|
||||
# Create new comment
|
||||
log(f"Creating new comment at: {comments_url}")
|
||||
response = requests.post(comments_url, headers=headers, json={'body': comment_body})
|
||||
|
||||
if response.status_code == 201:
|
||||
log("Successfully created new comment")
|
||||
else:
|
||||
log(f"Error creating comment: {response.status_code} - {response.text}")
|
||||
|
||||
def set_github_output(name, value):
|
||||
"""
|
||||
Set GitHub Actions output variable.
|
||||
|
||||
Args:
|
||||
name: Output variable name
|
||||
value: Output variable value
|
||||
"""
|
||||
# Write to the GitHub output file if available
|
||||
if 'GITHUB_OUTPUT' in os.environ:
|
||||
with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
|
||||
f.write(f"{name}={value}\n")
|
||||
else:
|
||||
# Fallback to the deprecated method for backward compatibility
|
||||
log(f"::set-output name={name}::{value}")
|
||||
|
||||
# Also print for human readability
|
||||
log(f"{name}: {value}")
|
||||
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
Utility module.
|
||||
This module provides utility functions used across the coverage check scripts.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import traceback
|
||||
from typing import List, Tuple, Dict, Any, Optional, Union
|
||||
|
||||
# List of allowed commands and their arguments
|
||||
ALLOWED_COMMANDS = {
|
||||
'xvfb-run': ['-a'],
|
||||
'npm': ['run', 'test:coverage', 'ci', 'install', '--no-save', '@vitest/coverage-v8', 'check-types', 'lint', 'format', 'compile'],
|
||||
'cd': ['webview-ui'],
|
||||
'python': ['-m', 'coverage_check'],
|
||||
'git': ['fetch', 'checkout', 'origin'],
|
||||
}
|
||||
|
||||
def is_safe_command(command: Union[str, List[str]]) -> bool:
|
||||
"""
|
||||
Check if a command is safe to execute.
|
||||
|
||||
Args:
|
||||
command: Command to check (string or list)
|
||||
|
||||
Returns:
|
||||
True if command is safe, False otherwise
|
||||
"""
|
||||
# Convert string command to list
|
||||
if isinstance(command, str):
|
||||
try:
|
||||
cmd_parts = shlex.split(command)
|
||||
except ValueError:
|
||||
return False
|
||||
else:
|
||||
cmd_parts = command
|
||||
|
||||
if not cmd_parts:
|
||||
return False
|
||||
|
||||
# Get base command
|
||||
base_cmd = os.path.basename(cmd_parts[0])
|
||||
|
||||
# Check if command is in allowed list
|
||||
if base_cmd not in ALLOWED_COMMANDS:
|
||||
return False
|
||||
|
||||
# For each argument, check for suspicious patterns
|
||||
for arg in cmd_parts[1:]:
|
||||
# Check for shell metacharacters
|
||||
if re.search(r'[;&|`$]', arg):
|
||||
return False
|
||||
# Check for path traversal
|
||||
if '..' in arg and not (base_cmd == 'npm' and arg.startswith('@')):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def log(message: str) -> None:
|
||||
"""
|
||||
Write a message to stdout and flush.
|
||||
|
||||
Args:
|
||||
message: The message to write
|
||||
"""
|
||||
sys.stdout.write(f"{message}\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
def file_exists(file_path: str) -> bool:
|
||||
"""
|
||||
Check if a file exists.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
True if the file exists, False otherwise
|
||||
"""
|
||||
return os.path.exists(file_path) and os.path.isfile(file_path)
|
||||
|
||||
def get_file_size(file_path: str) -> int:
|
||||
"""
|
||||
Get the size of a file in bytes.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
Size of the file in bytes, or 0 if the file doesn't exist
|
||||
"""
|
||||
if file_exists(file_path):
|
||||
return os.path.getsize(file_path)
|
||||
return 0
|
||||
|
||||
def list_directory(dir_path: str) -> List[Tuple[str, Union[int, str]]]:
|
||||
"""
|
||||
List the contents of a directory.
|
||||
|
||||
Args:
|
||||
dir_path: Path to the directory
|
||||
|
||||
Returns:
|
||||
List of (name, size) tuples for each file/directory in the directory
|
||||
"""
|
||||
if not os.path.exists(dir_path) or not os.path.isdir(dir_path):
|
||||
return []
|
||||
|
||||
contents = []
|
||||
for item in os.listdir(dir_path):
|
||||
item_path = os.path.join(dir_path, item)
|
||||
if os.path.isfile(item_path):
|
||||
contents.append((item, os.path.getsize(item_path)))
|
||||
else:
|
||||
contents.append((item, "DIR"))
|
||||
|
||||
return contents
|
||||
|
||||
def read_file_content(file_path: str, default: str = "") -> str:
|
||||
"""
|
||||
Read file content with error handling.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
default: Default value to return if file cannot be read
|
||||
|
||||
Returns:
|
||||
File content or default value
|
||||
"""
|
||||
if not file_exists(file_path):
|
||||
log(f"File does not exist: {file_path}")
|
||||
return default
|
||||
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
return f.read()
|
||||
except Exception as e:
|
||||
log(f"Error reading file {file_path}: {e}")
|
||||
return default
|
||||
|
||||
def write_file_content(file_path: str, content: str) -> bool:
|
||||
"""
|
||||
Write content to file with error handling.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
content: Content to write
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Create directory if it doesn't exist
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
|
||||
with open(file_path, 'w') as f:
|
||||
f.write(content)
|
||||
return True
|
||||
except Exception as e:
|
||||
log(f"Error writing to file {file_path}: {e}")
|
||||
return False
|
||||
|
||||
def run_command(command: Union[str, List[str]], capture_output: bool = True) -> Tuple[int, str, str]:
|
||||
"""
|
||||
Run a command and return the result.
|
||||
|
||||
Args:
|
||||
command: Command to run (string or list)
|
||||
capture_output: Whether to capture stdout/stderr
|
||||
|
||||
Returns:
|
||||
Tuple of (returncode, stdout, stderr)
|
||||
"""
|
||||
if not is_safe_command(command):
|
||||
error_msg = f"Unsafe command detected: {command}"
|
||||
log(error_msg)
|
||||
return 1, "", error_msg
|
||||
|
||||
log(f"Running command: {command}")
|
||||
try:
|
||||
# Convert string command to list
|
||||
if isinstance(command, str):
|
||||
cmd_list = shlex.split(command)
|
||||
else:
|
||||
cmd_list = command
|
||||
|
||||
result = subprocess.run(
|
||||
cmd_list,
|
||||
shell=False, # Never use shell=True for security
|
||||
capture_output=capture_output,
|
||||
text=True
|
||||
)
|
||||
log(f"Command exit code: {result.returncode}")
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
except Exception as e:
|
||||
log(f"Error running command: {e}")
|
||||
log(traceback.format_exc())
|
||||
return 1, "", str(e)
|
||||
|
||||
def find_pattern(content: str, pattern: str, group: int = 0,
|
||||
default: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
Find a pattern in content and return the specified group.
|
||||
|
||||
Args:
|
||||
content: Text content to search
|
||||
pattern: Regex pattern to search for
|
||||
group: Group number to return (default: 0 for entire match)
|
||||
default: Default value to return if pattern not found
|
||||
|
||||
Returns:
|
||||
Matched text or default value
|
||||
"""
|
||||
match = re.search(pattern, content, re.DOTALL)
|
||||
if match:
|
||||
return match.group(group)
|
||||
return default
|
||||
|
||||
def get_env_var(name: str, default: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
Get environment variable with default value.
|
||||
|
||||
Args:
|
||||
name: Environment variable name
|
||||
default: Default value if not set
|
||||
|
||||
Returns:
|
||||
Environment variable value or default
|
||||
"""
|
||||
return os.environ.get(name, default)
|
||||
|
||||
def format_exception(e: Exception) -> str:
|
||||
"""
|
||||
Format an exception with traceback for logging.
|
||||
|
||||
Args:
|
||||
e: Exception to format
|
||||
|
||||
Returns:
|
||||
Formatted exception string
|
||||
"""
|
||||
return f"{type(e).__name__}: {str(e)}\n{traceback.format_exc()}"
|
||||
@@ -0,0 +1,432 @@
|
||||
"""
|
||||
Workflow module.
|
||||
This module handles the main workflow logic for running coverage tests and processing results.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import subprocess
|
||||
import traceback
|
||||
|
||||
from .extraction import run_coverage, compare_coverage, extract_coverage
|
||||
from .github_api import generate_comment, post_comment, set_github_output
|
||||
from .util import log, file_exists, get_file_size, list_directory, run_command
|
||||
|
||||
def is_valid_branch_name(branch_name: str) -> bool:
|
||||
"""
|
||||
Validate a git branch name.
|
||||
|
||||
Args:
|
||||
branch_name: Branch name to validate
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
# Check for common branch name patterns
|
||||
if not re.match(r'^[a-zA-Z0-9_\-./]+$', branch_name):
|
||||
return False
|
||||
|
||||
# Check for path traversal
|
||||
if '..' in branch_name:
|
||||
return False
|
||||
|
||||
# Check for shell metacharacters
|
||||
if re.search(r'[;&|`$]', branch_name):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def checkout_branch(branch_name: str) -> None:
|
||||
"""
|
||||
Checkout a branch for testing.
|
||||
|
||||
Args:
|
||||
branch_name: Branch name to checkout
|
||||
|
||||
Raises:
|
||||
RuntimeError: If branch checkout fails
|
||||
ValueError: If branch name is invalid
|
||||
"""
|
||||
if not is_valid_branch_name(branch_name):
|
||||
raise ValueError(f"Invalid branch name: {branch_name}")
|
||||
|
||||
log(f"=== Checking out branch: {branch_name} ===")
|
||||
|
||||
# Fetch the branch
|
||||
returncode, stdout, stderr = run_command(['git', 'fetch', 'origin', branch_name])
|
||||
if returncode != 0:
|
||||
log(f"ERROR: Failed to fetch branch {branch_name}")
|
||||
log(f"Error details: {stderr}")
|
||||
raise RuntimeError(f"Git fetch failed: {stderr}")
|
||||
|
||||
# Checkout the branch
|
||||
returncode, stdout, stderr = run_command(['git', 'checkout', branch_name])
|
||||
if returncode != 0:
|
||||
log(f"ERROR: Failed to checkout branch {branch_name}")
|
||||
log(f"Error details: {stderr}")
|
||||
raise RuntimeError(f"Git checkout failed: {stderr}")
|
||||
|
||||
log(f"Successfully checked out branch: {branch_name}")
|
||||
|
||||
def extract_extension_coverage_from_file(file_path):
|
||||
"""Extract extension coverage from file when run_coverage returns 0."""
|
||||
if not file_exists(file_path):
|
||||
log(f"File {file_path} does not exist, cannot extract extension coverage")
|
||||
return 0.0
|
||||
|
||||
file_size = get_file_size(file_path)
|
||||
if file_size == 0:
|
||||
log(f"File {file_path} is empty, cannot extract extension coverage")
|
||||
return 0.0
|
||||
|
||||
log(f"Extension coverage is 0.0, trying to read from file directly: {file_path} (size: {file_size} bytes)")
|
||||
with open(file_path, 'r') as f:
|
||||
content = f.read()
|
||||
# Extract the percentage from the "Lines" row in the coverage summary
|
||||
# Pattern: Lines : xx.xx% ( xxxxxxx/xxxxxxx )
|
||||
lines_match = re.search(r'Lines\s*:\s*(\d+\.\d+)%', content)
|
||||
if lines_match:
|
||||
coverage = float(lines_match.group(1))
|
||||
log(f"Found extension coverage in file: {coverage}%")
|
||||
return coverage
|
||||
return 0.0
|
||||
|
||||
def extract_webview_coverage_from_file(file_path):
|
||||
"""Extract webview coverage from file when run_coverage returns 0."""
|
||||
if not file_exists(file_path):
|
||||
log(f"File {file_path} does not exist, cannot extract webview coverage")
|
||||
return 0.0
|
||||
|
||||
file_size = get_file_size(file_path)
|
||||
if file_size == 0:
|
||||
log(f"File {file_path} is empty, cannot extract webview coverage")
|
||||
return 0.0
|
||||
|
||||
log(f"Webview coverage is 0.0, trying to read from file directly: {file_path} (size: {file_size} bytes)")
|
||||
with open(file_path, 'r') as f:
|
||||
content = f.read()
|
||||
# Extract the percentage from the "% Lines" column in the "All files" row
|
||||
# Pattern: All files | xx.xx | xx.xx | xx.xx | xx.xx |
|
||||
all_files_match = re.search(r'All files\s+\|\s+\d+\.\d+\s+\|\s+\d+\.\d+\s+\|\s+\d+\.\d+\s+\|\s+(\d+\.\d+)', content)
|
||||
if all_files_match:
|
||||
coverage = float(all_files_match.group(1))
|
||||
log(f"Found webview coverage in file: {coverage}%")
|
||||
return coverage
|
||||
return 0.0
|
||||
|
||||
def run_extension_coverage(branch_name=None):
|
||||
"""Run extension coverage tests and extract results."""
|
||||
prefix = 'base_' if branch_name else ''
|
||||
file_path = f"{prefix}extension_coverage.txt"
|
||||
|
||||
# Run coverage tests
|
||||
ext_cov = run_coverage(
|
||||
["xvfb-run", "-a", "npm", "run", "test:coverage"],
|
||||
file_path,
|
||||
"extension"
|
||||
)
|
||||
|
||||
# If coverage is 0.0, try to extract from file directly
|
||||
if ext_cov == 0.0:
|
||||
ext_cov = extract_extension_coverage_from_file(file_path)
|
||||
|
||||
return ext_cov
|
||||
|
||||
def run_webview_coverage(branch_name=None):
|
||||
"""Run webview coverage tests and extract results."""
|
||||
prefix = 'base_' if branch_name else ''
|
||||
file_path = f"{prefix}webview_coverage.txt"
|
||||
|
||||
# Save current directory
|
||||
original_dir = os.getcwd()
|
||||
|
||||
try:
|
||||
# Change to webview-ui directory
|
||||
os.chdir('webview-ui')
|
||||
|
||||
# Install coverage dependency
|
||||
returncode, stdout, stderr = run_command(["npm", "install", "--no-save", "@vitest/coverage-v8"])
|
||||
if returncode != 0:
|
||||
log(f"Failed to install coverage dependency: {stderr}")
|
||||
return 0.0
|
||||
|
||||
# Run coverage tests from webview-ui directory
|
||||
web_cov = run_coverage(
|
||||
["npm", "run", "test:coverage"],
|
||||
os.path.join('..', file_path),
|
||||
"webview"
|
||||
)
|
||||
finally:
|
||||
# Always change back to original directory
|
||||
os.chdir(original_dir)
|
||||
|
||||
# If coverage is 0.0, try to extract from file directly
|
||||
if web_cov == 0.0:
|
||||
web_cov = extract_webview_coverage_from_file(file_path)
|
||||
|
||||
return web_cov
|
||||
|
||||
def run_branch_coverage(branch_name=None):
|
||||
"""
|
||||
Run coverage tests for a branch.
|
||||
|
||||
Args:
|
||||
branch_name: Name of the branch to checkout before running tests (optional)
|
||||
|
||||
Returns:
|
||||
Tuple of (extension_coverage, webview_coverage)
|
||||
"""
|
||||
# Checkout branch if specified
|
||||
if branch_name:
|
||||
checkout_branch(branch_name)
|
||||
|
||||
# Run coverage tests
|
||||
log(f"=== Running coverage tests{' for ' + branch_name if branch_name else ''} ===")
|
||||
|
||||
# Run extension and webview coverage
|
||||
ext_cov = run_extension_coverage(branch_name)
|
||||
web_cov = run_webview_coverage(branch_name)
|
||||
|
||||
return ext_cov, web_cov
|
||||
|
||||
def find_potential_coverage_files():
|
||||
"""Find potential coverage files in the current directory and webview-ui."""
|
||||
log("Searching for potential coverage files...")
|
||||
|
||||
# Find files in current directory
|
||||
current_dir_files = list_directory('.')
|
||||
for name, size in current_dir_files:
|
||||
if 'coverage' in name.lower() and size != "DIR":
|
||||
log(f"Found potential coverage file: {name} (size: {size} bytes)")
|
||||
|
||||
# Find files in webview-ui directory
|
||||
if os.path.exists('webview-ui') and os.path.isdir('webview-ui'):
|
||||
webview_files = list_directory('webview-ui')
|
||||
for name, size in webview_files:
|
||||
if 'coverage' in name.lower() and size != "DIR":
|
||||
log(f"Found potential webview coverage file: webview-ui/{name} (size: {size} bytes)")
|
||||
else:
|
||||
log("webview-ui directory not found")
|
||||
|
||||
def generate_warnings(base_ext_cov, pr_ext_cov, ext_decreased, ext_diff,
|
||||
base_web_cov, pr_web_cov, web_decreased, web_diff):
|
||||
"""Generate warnings for coverage decreases."""
|
||||
if not (ext_decreased or web_decreased):
|
||||
return []
|
||||
|
||||
warnings = [
|
||||
"Test coverage has decreased in this PR",
|
||||
f"Extension coverage: {base_ext_cov}% -> {pr_ext_cov}% (Diff: {ext_diff}%)",
|
||||
f"Webview coverage: {base_web_cov}% -> {pr_web_cov}% (Diff: {web_diff}%)"
|
||||
]
|
||||
|
||||
# Additional warning for significant decrease (more than 1%)
|
||||
if ext_decreased and ext_diff > 1.0:
|
||||
warnings.append(f"Extension coverage decreased by more than 1% ({ext_diff}%). Consider adding tests to cover your changes.")
|
||||
|
||||
if web_decreased and web_diff > 1.0:
|
||||
warnings.append(f"Webview coverage decreased by more than 1% ({web_diff}%). Consider adding tests to cover your changes.")
|
||||
|
||||
return warnings
|
||||
|
||||
def output_warnings(warnings):
|
||||
"""Output warnings to GitHub step summary and console."""
|
||||
if not warnings:
|
||||
return
|
||||
|
||||
# Get the GitHub step summary file path from environment variable
|
||||
github_step_summary = os.environ.get('GITHUB_STEP_SUMMARY')
|
||||
|
||||
# Write to GitHub step summary if available
|
||||
if github_step_summary:
|
||||
with open(github_step_summary, 'a') as f:
|
||||
f.write("## Coverage Warnings\n\n")
|
||||
for warning in warnings:
|
||||
f.write(f"⚠️ {warning}\n\n")
|
||||
|
||||
# Also output to console with ::warning:: syntax for backward compatibility
|
||||
for warning in warnings:
|
||||
log(f"::warning::{warning}")
|
||||
|
||||
def output_github_results(pr_ext_cov, pr_web_cov, base_ext_cov, base_web_cov,
|
||||
ext_decreased, ext_diff, web_decreased, web_diff):
|
||||
"""Output results for GitHub Actions."""
|
||||
set_github_output("pr_extension_coverage", pr_ext_cov)
|
||||
set_github_output("pr_webview_coverage", pr_web_cov)
|
||||
set_github_output("base_extension_coverage", base_ext_cov)
|
||||
set_github_output("base_webview_coverage", base_web_cov)
|
||||
set_github_output("extension_decreased", str(ext_decreased).lower())
|
||||
set_github_output("extension_diff", ext_diff)
|
||||
set_github_output("webview_decreased", str(web_decreased).lower())
|
||||
set_github_output("webview_diff", web_diff)
|
||||
|
||||
def extract_pr_coverage_from_artifacts():
|
||||
"""
|
||||
Extract PR branch coverage from artifact files.
|
||||
|
||||
Returns:
|
||||
Tuple of (extension_coverage, webview_coverage)
|
||||
|
||||
Raises:
|
||||
SystemExit: If the coverage files don't exist
|
||||
"""
|
||||
log("=== Extracting PR branch coverage from artifacts ===")
|
||||
|
||||
# Check if the coverage files exist
|
||||
ext_file_path = "extension_coverage.txt"
|
||||
web_file_path = "webview-ui/webview_coverage.txt"
|
||||
|
||||
# Extract extension coverage
|
||||
log(f"Extracting extension coverage from {ext_file_path}")
|
||||
if not file_exists(ext_file_path):
|
||||
error_msg = f"ERROR: PR extension coverage file {ext_file_path} not found"
|
||||
log(error_msg)
|
||||
|
||||
# List directory contents for debugging
|
||||
log("Current directory contents:")
|
||||
try:
|
||||
dir_contents = list_directory('.')
|
||||
for name, size in dir_contents:
|
||||
log(f" {name} - {size}\n")
|
||||
except Exception as e:
|
||||
log(f"Error listing directory: {e}")
|
||||
|
||||
sys.exit(1) # Exit with error code to fail the workflow
|
||||
|
||||
ext_cov = extract_extension_coverage_from_file(ext_file_path)
|
||||
log(f"PR extension coverage from artifact: {ext_cov}%")
|
||||
|
||||
# Extract webview coverage
|
||||
log(f"Extracting webview coverage from {web_file_path}")
|
||||
if not file_exists(web_file_path):
|
||||
error_msg = f"ERROR: PR webview coverage file {web_file_path} not found"
|
||||
log(error_msg)
|
||||
|
||||
# Check if the webview-ui directory exists
|
||||
if not os.path.exists('webview-ui'):
|
||||
log("ERROR: webview-ui directory not found")
|
||||
else:
|
||||
# List webview-ui directory contents for debugging
|
||||
log("webview-ui directory contents:")
|
||||
try:
|
||||
dir_contents = list_directory('webview-ui')
|
||||
for name, size in dir_contents:
|
||||
log(f" {name} - {size}")
|
||||
except Exception as e:
|
||||
log(f"Error listing directory: {e}")
|
||||
|
||||
sys.exit(1) # Exit with error code to fail the workflow
|
||||
|
||||
web_cov = extract_webview_coverage_from_file(web_file_path)
|
||||
log(f"PR webview coverage from artifact: {web_cov}%")
|
||||
|
||||
return ext_cov, web_cov
|
||||
|
||||
def process_coverage_workflow(args):
|
||||
"""
|
||||
Process the entire coverage workflow.
|
||||
|
||||
Args:
|
||||
args: Command line arguments
|
||||
"""
|
||||
# Initialize all variables at the start
|
||||
pr_ext_cov = 0.0
|
||||
pr_web_cov = 0.0
|
||||
base_ext_cov = 0.0
|
||||
base_web_cov = 0.0
|
||||
ext_decreased = False
|
||||
ext_diff = 0.0
|
||||
web_decreased = False
|
||||
web_diff = 0.0
|
||||
|
||||
try:
|
||||
# Validate branch name
|
||||
if not is_valid_branch_name(args.base_branch):
|
||||
raise ValueError(f"Invalid base branch name: {args.base_branch}")
|
||||
|
||||
# Check if we're running in GitHub Actions
|
||||
is_github_actions = 'GITHUB_ACTIONS' in os.environ
|
||||
if is_github_actions:
|
||||
log("Running in GitHub Actions environment")
|
||||
|
||||
# Extract PR branch coverage from artifacts (from test job)
|
||||
pr_ext_cov, pr_web_cov = extract_pr_coverage_from_artifacts()
|
||||
|
||||
# Verify PR coverage values
|
||||
if pr_ext_cov == 0.0:
|
||||
log("WARNING: PR extension coverage is 0.0, this may indicate an issue with the coverage report")
|
||||
find_potential_coverage_files()
|
||||
|
||||
if pr_web_cov == 0.0:
|
||||
log("WARNING: PR webview coverage is 0.0, this may indicate an issue with the coverage report")
|
||||
find_potential_coverage_files()
|
||||
|
||||
# Run base branch coverage
|
||||
log(f"=== Running base branch coverage for {args.base_branch} ===")
|
||||
base_ext_cov, base_web_cov = run_branch_coverage(args.base_branch)
|
||||
|
||||
# Verify base coverage values
|
||||
if base_ext_cov == 0.0:
|
||||
log("WARNING: Base extension coverage is 0.0, this may indicate an issue with the coverage report")
|
||||
|
||||
if base_web_cov == 0.0:
|
||||
log("WARNING: Base webview coverage is 0.0, this may indicate an issue with the coverage report")
|
||||
|
||||
# Compare coverage
|
||||
log("=== Comparing extension coverage ===")
|
||||
ext_decreased, ext_diff = compare_coverage(base_ext_cov, pr_ext_cov)
|
||||
|
||||
log("=== Comparing webview coverage ===")
|
||||
web_decreased, web_diff = compare_coverage(base_web_cov, pr_web_cov)
|
||||
|
||||
# Print summary of coverage values
|
||||
log("\n=== Coverage Summary ===")
|
||||
log(f"PR extension coverage: {pr_ext_cov}%")
|
||||
log(f"Base extension coverage: {base_ext_cov}%")
|
||||
log(f"Extension coverage change: {'+' if not ext_decreased else '-'}{ext_diff}%")
|
||||
log(f"PR webview coverage: {pr_web_cov}%")
|
||||
log(f"Base webview coverage: {base_web_cov}%")
|
||||
log(f"Webview coverage change: {'+' if not web_decreased else '-'}{web_diff}%")
|
||||
|
||||
# Generate and output warnings
|
||||
warnings = generate_warnings(
|
||||
base_ext_cov, pr_ext_cov, ext_decreased, ext_diff,
|
||||
base_web_cov, pr_web_cov, web_decreased, web_diff
|
||||
)
|
||||
output_warnings(warnings)
|
||||
|
||||
# Generate comment
|
||||
log("=== Generating comment ===")
|
||||
comment = generate_comment(
|
||||
base_ext_cov, pr_ext_cov, str(ext_decreased).lower(), ext_diff,
|
||||
base_web_cov, pr_web_cov, str(web_decreased).lower(), web_diff
|
||||
)
|
||||
|
||||
# Save comment to file
|
||||
with open("coverage_comment.md", "w") as f:
|
||||
f.write(comment)
|
||||
|
||||
# Post comment if PR number is provided
|
||||
if args.pr_number:
|
||||
log(f"=== Posting comment to PR #{args.pr_number} ===")
|
||||
post_comment("coverage_comment.md", args.pr_number, args.repo, args.token)
|
||||
|
||||
# Output results for GitHub Actions
|
||||
output_github_results(
|
||||
pr_ext_cov, pr_web_cov, base_ext_cov, base_web_cov,
|
||||
ext_decreased, ext_diff, web_decreased, web_diff
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log(f"ERROR in process_coverage_workflow: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
# Try to output results even if there was an error
|
||||
try:
|
||||
output_github_results(
|
||||
pr_ext_cov, pr_web_cov, base_ext_cov, base_web_cov,
|
||||
ext_decreased, ext_diff, web_decreased, web_diff
|
||||
)
|
||||
except Exception as e2:
|
||||
log(f"ERROR outputting GitHub results: {e2}")
|
||||
@@ -22,7 +22,6 @@ Environment Variables:
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
CHANGELOG_PATH = os.environ.get("CHANGELOG_PATH", "CHANGELOG.md")
|
||||
VERSION = os.environ['VERSION']
|
||||
@@ -32,72 +31,49 @@ NEW_CONTENT = os.environ.get("NEW_CONTENT", "")
|
||||
def overwrite_changelog_section(changelog_text: str, new_content: str):
|
||||
# Find the section for the specified version
|
||||
version_pattern = f"## {VERSION}\n"
|
||||
bracketed_version_pattern = f"## [{VERSION}]\n"
|
||||
unformmatted_prev_version_pattern = f"## {PREV_VERSION}\n"
|
||||
prev_version_pattern = f"## [{PREV_VERSION}]\n"
|
||||
print(f"latest version: {VERSION}")
|
||||
print(f"prev_version: {PREV_VERSION}")
|
||||
|
||||
# Try both unbracketed and bracketed version patterns
|
||||
version_index = changelog_text.find(version_pattern)
|
||||
if version_index == -1:
|
||||
version_index = changelog_text.find(bracketed_version_pattern)
|
||||
if version_index == -1:
|
||||
# If version not found, add it at the top (after the first line)
|
||||
first_newline = changelog_text.find('\n')
|
||||
if first_newline == -1:
|
||||
# If no newline found, just prepend
|
||||
return f"## [{VERSION}]\n\n{changelog_text}"
|
||||
return f"{changelog_text[:first_newline + 1]}## [{VERSION}]\n\n{changelog_text[first_newline + 1:]}"
|
||||
else:
|
||||
# Using bracketed version
|
||||
version_pattern = bracketed_version_pattern
|
||||
|
||||
notes_start_index = version_index + len(version_pattern)
|
||||
notes_end_index = changelog_text.find(prev_version_pattern, notes_start_index) if PREV_VERSION and prev_version_pattern in changelog_text else len(changelog_text)
|
||||
notes_start_index = changelog_text.find(version_pattern) + len(version_pattern)
|
||||
notes_end_index = changelog_text.find(prev_version_pattern, notes_start_index) if PREV_VERSION and (prev_version_pattern in changelog_text or unformmatted_prev_version_pattern in changelog_text) else len(changelog_text)
|
||||
|
||||
if new_content:
|
||||
return changelog_text[:notes_start_index] + f"{new_content}\n" + changelog_text[notes_end_index:]
|
||||
else:
|
||||
changeset_lines = changelog_text[notes_start_index:notes_end_index].split("\n")
|
||||
# Ensure we have at least 2 lines before removing them
|
||||
if len(changeset_lines) < 2:
|
||||
print("Warning: Changeset content has fewer than 2 lines")
|
||||
parsed_lines = "\n".join(changeset_lines)
|
||||
else:
|
||||
# Remove the first two lines from the regular changeset format, ex: \n### Patch Changes
|
||||
parsed_lines = "\n".join(changeset_lines[2:])
|
||||
filtered_lines = []
|
||||
for line in changeset_lines:
|
||||
# If the previous line is a changeset format
|
||||
if len(filtered_lines) > 1 and filtered_lines[-1].startswith("### "):
|
||||
# Remove the last two lines from the filted_lines
|
||||
filtered_lines.pop()
|
||||
filtered_lines.pop()
|
||||
else:
|
||||
filtered_lines.append(line.strip())
|
||||
|
||||
# Prepend a new line to the first line of filtered_lines
|
||||
if filtered_lines:
|
||||
filtered_lines[0] = "\n" + filtered_lines[0]
|
||||
|
||||
# Print filted_lines wiht a "\n" at the end of each line
|
||||
for line in filtered_lines:
|
||||
print(line.strip())
|
||||
|
||||
parsed_lines = "\n".join(line for line in filtered_lines)
|
||||
updated_changelog = changelog_text[:notes_start_index] + parsed_lines + changelog_text[notes_end_index:]
|
||||
# Ensure version number is bracketed
|
||||
updated_changelog = updated_changelog.replace(f"## {VERSION}", f"## [{VERSION}]")
|
||||
return updated_changelog
|
||||
|
||||
try:
|
||||
print(f"Reading changelog from: {CHANGELOG_PATH}")
|
||||
with open(CHANGELOG_PATH, 'r') as f:
|
||||
changelog_content = f.read()
|
||||
with open(CHANGELOG_PATH, 'r') as f:
|
||||
changelog_content = f.read()
|
||||
|
||||
print(f"Changelog content length: {len(changelog_content)} characters")
|
||||
print("First 200 characters of changelog:")
|
||||
print(changelog_content[:200])
|
||||
print("----------------------------------------------------------------------------------")
|
||||
new_changelog = overwrite_changelog_section(changelog_content, NEW_CONTENT)
|
||||
# print("----------------------------------------------------------------------------------")
|
||||
# print(new_changelog)
|
||||
# print("----------------------------------------------------------------------------------")
|
||||
# Write back to CHANGELOG.md
|
||||
with open(CHANGELOG_PATH, 'w') as f:
|
||||
f.write(new_changelog)
|
||||
|
||||
new_changelog = overwrite_changelog_section(changelog_content, NEW_CONTENT)
|
||||
|
||||
print("New changelog content:")
|
||||
print("----------------------------------------------------------------------------------")
|
||||
print(new_changelog)
|
||||
print("----------------------------------------------------------------------------------")
|
||||
|
||||
print(f"Writing updated changelog back to: {CHANGELOG_PATH}")
|
||||
with open(CHANGELOG_PATH, 'w') as f:
|
||||
f.write(new_changelog)
|
||||
|
||||
print(f"{CHANGELOG_PATH} updated successfully!")
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"Error: Changelog file not found at {CHANGELOG_PATH}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Error updating changelog: {str(e)}")
|
||||
print(f"Current working directory: {os.getcwd()}")
|
||||
sys.exit(1)
|
||||
print(f"{CHANGELOG_PATH} updated successfully!")
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for coverage_check script.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
import subprocess
|
||||
import tempfile
|
||||
from unittest.mock import patch, MagicMock, call, mock_open
|
||||
|
||||
# Add parent directory to path so we can import coverage modules
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
from coverage_check import extract_coverage, compare_coverage, set_verbose, generate_comment, post_comment, set_github_output
|
||||
from coverage_check.util import log, file_exists, get_file_size, list_directory
|
||||
|
||||
|
||||
class TestCoverage(unittest.TestCase):
|
||||
# Class variables to store coverage files
|
||||
temp_dir = None
|
||||
extension_coverage_file = None
|
||||
webview_coverage_file = None
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Set up test environment once for all tests."""
|
||||
# Create temporary directory for test files
|
||||
cls.temp_dir = tempfile.TemporaryDirectory()
|
||||
cls.extension_coverage_file = os.path.join(cls.temp_dir.name, 'extension_coverage.txt')
|
||||
cls.webview_coverage_file = os.path.join(cls.temp_dir.name, 'webview_coverage.txt')
|
||||
|
||||
# Run actual tests to generate coverage reports
|
||||
cls.generate_coverage_reports()
|
||||
|
||||
# Verify files exist and are not empty
|
||||
assert os.path.exists(cls.extension_coverage_file), \
|
||||
f"Extension coverage file {cls.extension_coverage_file} does not exist"
|
||||
assert os.path.getsize(cls.extension_coverage_file) > 0, \
|
||||
f"Extension coverage file {cls.extension_coverage_file} is empty"
|
||||
assert os.path.exists(cls.webview_coverage_file), \
|
||||
f"Webview coverage file {cls.webview_coverage_file} does not exist"
|
||||
assert os.path.getsize(cls.webview_coverage_file) > 0, \
|
||||
f"Webview coverage file {cls.webview_coverage_file} is empty"
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
"""Clean up test environment after all tests."""
|
||||
if cls.temp_dir:
|
||||
cls.temp_dir.cleanup()
|
||||
|
||||
@classmethod
|
||||
def generate_coverage_reports(cls):
|
||||
"""Generate real coverage reports by running tests."""
|
||||
log("Generating coverage reports (this may take a while)...")
|
||||
|
||||
# Run extension tests with coverage
|
||||
try:
|
||||
# Get absolute paths
|
||||
root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..'))
|
||||
webview_dir = os.path.join(root_dir, 'webview-ui')
|
||||
|
||||
# Use xvfb-run on Linux
|
||||
if sys.platform.startswith('linux'):
|
||||
cmd = f"cd {root_dir} && xvfb-run -a npm run test:coverage > {cls.extension_coverage_file} 2>&1"
|
||||
else:
|
||||
cmd = f"cd {root_dir} && npm run test:coverage > {cls.extension_coverage_file} 2>&1"
|
||||
|
||||
log("Running extension tests...")
|
||||
log(f"Command: {cmd}")
|
||||
result = subprocess.run(cmd, shell=True, check=False, capture_output=True, text=True)
|
||||
log(f"Extension tests exit code: {result.returncode}")
|
||||
|
||||
# Run webview tests with coverage
|
||||
log("Running webview tests...")
|
||||
cmd = f"cd {webview_dir} && npm run test:coverage > {cls.webview_coverage_file} 2>&1"
|
||||
log(f"Command: {cmd}")
|
||||
result = subprocess.run(cmd, shell=True, check=False, capture_output=True, text=True)
|
||||
log(f"Webview tests exit code: {result.returncode}")
|
||||
|
||||
# Verify files were created
|
||||
if file_exists(cls.extension_coverage_file):
|
||||
ext_size = get_file_size(cls.extension_coverage_file)
|
||||
log(f"Extension coverage file created: {cls.extension_coverage_file} (size: {ext_size} bytes)")
|
||||
else:
|
||||
log(f"WARNING: Extension coverage file was not created: {cls.extension_coverage_file}")
|
||||
|
||||
if file_exists(cls.webview_coverage_file):
|
||||
web_size = get_file_size(cls.webview_coverage_file)
|
||||
log(f"Webview coverage file created: {cls.webview_coverage_file} (size: {web_size} bytes)")
|
||||
else:
|
||||
log(f"WARNING: Webview coverage file was not created: {cls.webview_coverage_file}")
|
||||
|
||||
log("Coverage reports generation completed.")
|
||||
except Exception as e:
|
||||
log(f"Error generating coverage reports: {e}")
|
||||
import traceback
|
||||
log(traceback.format_exc())
|
||||
|
||||
# Create empty files if tests fail
|
||||
log("Creating fallback coverage files...")
|
||||
with open(cls.extension_coverage_file, 'w') as f:
|
||||
f.write("No coverage data available")
|
||||
with open(cls.webview_coverage_file, 'w') as f:
|
||||
f.write("No coverage data available")
|
||||
|
||||
def test_extract_coverage(self):
|
||||
"""Test extract_coverage function with both extension and webview coverage."""
|
||||
# Check if verbose mode is enabled
|
||||
if '-v' in sys.argv or '--verbose' in sys.argv:
|
||||
set_verbose(True)
|
||||
|
||||
# Verify files exist before testing
|
||||
self.assertTrue(file_exists(self.extension_coverage_file),
|
||||
f"Extension coverage file does not exist: {self.extension_coverage_file}")
|
||||
self.assertTrue(file_exists(self.webview_coverage_file),
|
||||
f"Webview coverage file does not exist: {self.webview_coverage_file}")
|
||||
|
||||
# Log file sizes
|
||||
ext_size = get_file_size(self.extension_coverage_file)
|
||||
web_size = get_file_size(self.webview_coverage_file)
|
||||
log(f"Extension coverage file size: {ext_size} bytes")
|
||||
log(f"Webview coverage file size: {web_size} bytes")
|
||||
|
||||
# Test extension coverage
|
||||
log("Testing extension coverage extraction...")
|
||||
ext_coverage_pct = extract_coverage(self.extension_coverage_file, 'extension')
|
||||
|
||||
# Check that coverage percentage is a float
|
||||
self.assertIsInstance(ext_coverage_pct, float)
|
||||
|
||||
# Check that coverage percentage is between 0 and 100
|
||||
self.assertGreaterEqual(ext_coverage_pct, 0)
|
||||
self.assertLessEqual(ext_coverage_pct, 100)
|
||||
|
||||
# Log coverage percentage for debugging
|
||||
log(f"Extension coverage: {ext_coverage_pct}%")
|
||||
|
||||
# Test webview coverage
|
||||
log("Testing webview coverage extraction...")
|
||||
web_coverage_pct = extract_coverage(self.webview_coverage_file, 'webview')
|
||||
|
||||
# Convert to float if it's an integer
|
||||
if isinstance(web_coverage_pct, int):
|
||||
web_coverage_pct = float(web_coverage_pct)
|
||||
|
||||
# Check that coverage percentage is a float
|
||||
self.assertIsInstance(web_coverage_pct, float)
|
||||
|
||||
# Check that coverage percentage is between 0 and 100
|
||||
self.assertGreaterEqual(web_coverage_pct, 0)
|
||||
self.assertLessEqual(web_coverage_pct, 100)
|
||||
|
||||
# Log coverage percentage for debugging
|
||||
log(f"Webview coverage: {web_coverage_pct}%")
|
||||
|
||||
def test_compare_coverage(self):
|
||||
"""Test compare_coverage function."""
|
||||
# Test with coverage increase
|
||||
decreased, diff = compare_coverage(80, 90)
|
||||
self.assertFalse(decreased)
|
||||
self.assertEqual(diff, 10)
|
||||
|
||||
# Test with coverage decrease
|
||||
decreased, diff = compare_coverage(90, 80)
|
||||
self.assertTrue(decreased)
|
||||
self.assertEqual(diff, 10)
|
||||
|
||||
# Test with no change
|
||||
decreased, diff = compare_coverage(80, 80)
|
||||
self.assertFalse(decreased)
|
||||
self.assertEqual(diff, 0)
|
||||
|
||||
def test_generate_comment(self):
|
||||
"""Test generate_comment function."""
|
||||
comment = generate_comment(
|
||||
80, 90, 'false', 10,
|
||||
70, 75, 'false', 5
|
||||
)
|
||||
|
||||
# Check that comment contains expected sections
|
||||
self.assertIn('Coverage Report', comment)
|
||||
self.assertIn('Extension Coverage', comment)
|
||||
self.assertIn('Webview Coverage', comment)
|
||||
self.assertIn('Overall Assessment', comment)
|
||||
|
||||
# Check that comment contains coverage percentages
|
||||
self.assertIn('Base branch: 80%', comment)
|
||||
self.assertIn('PR branch: 90%', comment)
|
||||
self.assertIn('Base branch: 70%', comment)
|
||||
self.assertIn('PR branch: 75%', comment)
|
||||
|
||||
# Check that comment contains correct assessment
|
||||
self.assertIn('Coverage increased or remained the same', comment)
|
||||
self.assertIn('Test coverage has been maintained or improved', comment)
|
||||
|
||||
@patch('coverage_check.requests.get')
|
||||
@patch('coverage_check.requests.post')
|
||||
@patch('coverage_check.requests.patch')
|
||||
def test_post_comment_new(self, mock_patch, mock_post, mock_get):
|
||||
"""Test post_comment function when creating a new comment."""
|
||||
# Create a temporary comment file
|
||||
comment_file = os.path.join(self.temp_dir.name, 'comment.md')
|
||||
with open(comment_file, 'w') as f:
|
||||
f.write('<!-- COVERAGE_REPORT -->\nTest comment')
|
||||
|
||||
# Mock the API responses
|
||||
mock_get.return_value = MagicMock(status_code=200, json=lambda: [])
|
||||
mock_post.return_value = MagicMock(status_code=201)
|
||||
|
||||
# Test post_comment function
|
||||
post_comment(comment_file, '123', 'owner/repo', 'token')
|
||||
|
||||
# Check that the correct API calls were made
|
||||
mock_get.assert_called_once()
|
||||
mock_post.assert_called_once()
|
||||
mock_patch.assert_not_called()
|
||||
|
||||
@patch('coverage_check.requests.get')
|
||||
@patch('coverage_check.requests.post')
|
||||
@patch('coverage_check.requests.patch')
|
||||
def test_post_comment_update(self, mock_patch, mock_post, mock_get):
|
||||
"""Test post_comment function when updating an existing comment."""
|
||||
# Create a temporary comment file
|
||||
comment_file = os.path.join(self.temp_dir.name, 'comment.md')
|
||||
with open(comment_file, 'w') as f:
|
||||
f.write('<!-- COVERAGE_REPORT -->\nTest comment')
|
||||
|
||||
# Mock the API responses
|
||||
mock_get.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: [{'id': 456, 'body': '<!-- COVERAGE_REPORT -->\nOld comment'}]
|
||||
)
|
||||
mock_patch.return_value = MagicMock(status_code=200)
|
||||
|
||||
# Test post_comment function
|
||||
post_comment(comment_file, '123', 'owner/repo', 'token')
|
||||
|
||||
# Check that the correct API calls were made
|
||||
mock_get.assert_called_once()
|
||||
mock_patch.assert_called_once()
|
||||
mock_post.assert_not_called()
|
||||
|
||||
def test_set_github_output(self):
|
||||
"""Test set_github_output function."""
|
||||
# Capture stdout
|
||||
with patch('sys.stdout', new=MagicMock()) as mock_stdout:
|
||||
# Mock environment without GITHUB_OUTPUT
|
||||
with patch.dict('os.environ', {}, clear=True):
|
||||
set_github_output('test_name', 'test_value')
|
||||
|
||||
# Check that the correct output was printed to stdout
|
||||
mock_stdout.assert_has_calls([
|
||||
# GitHub Actions output format (deprecated method)
|
||||
call.write('::set-output name=test_name::test_value\n'),
|
||||
call.flush(),
|
||||
# Human readable format
|
||||
call.write('test_name: test_value\n'),
|
||||
call.flush()
|
||||
], any_order=False)
|
||||
|
||||
# Reset mock for next test
|
||||
mock_stdout.reset_mock()
|
||||
|
||||
# Test with GITHUB_OUTPUT environment variable
|
||||
with patch.dict('os.environ', {'GITHUB_OUTPUT': '/tmp/github_output'}), \
|
||||
patch('builtins.open', mock_open()) as mock_file:
|
||||
set_github_output('test_name', 'test_value')
|
||||
|
||||
# Check that file was written to
|
||||
mock_file.assert_called_once_with('/tmp/github_output', 'a')
|
||||
mock_file().write.assert_called_once_with('test_name=test_value\n')
|
||||
|
||||
# Check that human readable output was printed
|
||||
mock_stdout.assert_has_calls([
|
||||
call.write('test_name: test_value\n'),
|
||||
call.flush()
|
||||
], any_order=False)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,112 @@
|
||||
name: Changeset Converter
|
||||
run-name: Changeset Conversion
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
env:
|
||||
REPO_PATH: ${{ github.repository }}
|
||||
GIT_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || 'main' }}
|
||||
NODE_VERSION: 20.18.1
|
||||
|
||||
jobs:
|
||||
# Job 1: Create version bump PR when changesets are merged to main
|
||||
changeset-pr-version-bump:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.merged == true &&
|
||||
github.event.pull_request.base.ref == 'main' &&
|
||||
github.actor != 'github-actions'
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check user for team affiliation
|
||||
id: team_check
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: morfien101/actions-authorized-user@4a3cfbf0bcb3cafe4a71710a278920c5d94bb38b
|
||||
with:
|
||||
username: ${{ github.actor }}
|
||||
team: "deployer"
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check if user is authorized
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
if [ "${{ steps.team_check.outputs.authorized }}" != "true" ]; then
|
||||
echo "User is not authorized to run this workflow."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Git Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ env.GIT_REF }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install changeset
|
||||
|
||||
# Check if there are any new changesets to process
|
||||
- name: Check for changesets
|
||||
id: check-changesets
|
||||
run: |
|
||||
NEW_CHANGESETS=$(find .changeset -name "*.md" ! -name "README.md" | wc -l | tr -d ' ')
|
||||
echo "Changesets diff with previous version: $NEW_CHANGESETS"
|
||||
echo "new_changesets=$NEW_CHANGESETS" >> $GITHUB_OUTPUT
|
||||
|
||||
# Create version bump PR using changesets/action if there are new changesets
|
||||
- name: Create Changeset Pull Request
|
||||
if: steps.check-changesets.outputs.new_changesets != '0'
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
commit: "changeset version bump"
|
||||
title: "Changeset version bump"
|
||||
version: npm run version-packages # This performs the changeset version bump
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Get current and previous versions to edit changelog entry
|
||||
- name: Get version
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(git show HEAD:package.json | jq -r '.version')
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
PREV_VERSION=$(git show origin/main:package.json | jq -r '.version')
|
||||
echo "prev_version=$PREV_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION"
|
||||
echo "prev_version=$PREV_VERSION"
|
||||
|
||||
# Update CHANGELOG.md with proper format
|
||||
- name: Update Changelog Format
|
||||
env:
|
||||
VERSION: ${{ steps.get_version.outputs.version }}
|
||||
PREV_VERSION: ${{ steps.get_version.outputs.prev_version }}
|
||||
run: python .github/scripts/overwrite_changeset_changelog.py
|
||||
|
||||
# Commit and push changelog updates
|
||||
- name: Push Changelog updates to Pull Request
|
||||
run: |
|
||||
git config user.name "github-actions"
|
||||
git config user.email github-actions@github.com
|
||||
echo "Running git add and commit..."
|
||||
git add CHANGELOG.md
|
||||
git commit -m "Updating CHANGELOG.md format"
|
||||
git status
|
||||
echo "--------------------------------------------------------------------------------"
|
||||
echo "Pushing to remote..."
|
||||
echo "--------------------------------------------------------------------------------"
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
git push origin $CURRENT_BRANCH
|
||||
@@ -1,117 +0,0 @@
|
||||
name: Check Changeset
|
||||
run-name: Check for Changeset in PR
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
jobs:
|
||||
check-changeset:
|
||||
# Skip draft PRs and dependabot PRs
|
||||
if: github.event.pull_request.draft == false && github.actor != 'dependabot[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Check for changeset
|
||||
id: check-changeset
|
||||
run: |
|
||||
# Debug info
|
||||
echo "Current directory: $(pwd)"
|
||||
echo "PR Base Ref: ${{ github.event.pull_request.base.ref }}"
|
||||
echo "PR Head Ref: ${{ github.event.pull_request.head.ref }}"
|
||||
echo "PR Head SHA: ${{ github.event.pull_request.head.sha }}"
|
||||
echo "Git status:"
|
||||
git status
|
||||
|
||||
# Get list of changed files
|
||||
git fetch origin ${{ github.event.pull_request.base.ref }}
|
||||
CHANGED_FILES=$(git diff --name-only origin/${{ github.event.pull_request.base.ref }} HEAD)
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED_FILES"
|
||||
|
||||
# Check if any of the changed files are in docs/ or .github/
|
||||
echo "Checking if changes are docs-only..."
|
||||
DOCS_ONLY=true
|
||||
while IFS= read -r file; do
|
||||
if [[ ! "$file" =~ ^(docs/|.github/) ]]; then
|
||||
echo "Found non-docs change: $file"
|
||||
DOCS_ONLY=false
|
||||
break
|
||||
fi
|
||||
done <<< "$CHANGED_FILES"
|
||||
|
||||
# If changes are docs-only, skip changeset check
|
||||
if [ "$DOCS_ONLY" = true ]; then
|
||||
echo "All changes are in docs/ or .github/, skipping changeset check"
|
||||
exit 0
|
||||
else
|
||||
echo "Changes include non-docs files, checking for changeset..."
|
||||
fi
|
||||
|
||||
# Check if any changeset files are in the changed files
|
||||
echo "Checking for changeset files in changed files..."
|
||||
CHANGESET_IN_PR=false
|
||||
while IFS= read -r file; do
|
||||
if [[ "$file" =~ ^\.changeset/.*\.md$ && "$file" != ".changeset/README.md" && "$file" != ".changeset/config.json" ]]; then
|
||||
echo "Found changeset file in PR: $file"
|
||||
CHANGESET_IN_PR=true
|
||||
break
|
||||
fi
|
||||
done <<< "$CHANGED_FILES"
|
||||
|
||||
if [ "$CHANGESET_IN_PR" = false ]; then
|
||||
echo "No changeset files found in changed files. Changed files in .changeset/:"
|
||||
echo "$CHANGED_FILES" | grep "^\.changeset/" || true
|
||||
echo "::error::No changeset file found in PR changes. Please run 'npm run changeset' to create one."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Comment on PR
|
||||
if: failure()
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
with:
|
||||
script: |
|
||||
const message = `This PR requires a changeset since it includes user-facing changes. Please:
|
||||
|
||||
1. Run \`npm run changeset\` locally
|
||||
2. Choose the appropriate version bump:
|
||||
- \`major\` for breaking changes (1.0.0 → 2.0.0)
|
||||
- \`minor\` for new features (1.0.0 → 1.1.0)
|
||||
- \`patch\` for bug fixes (1.0.0 → 1.0.1)
|
||||
3. Write a clear description of your changes
|
||||
4. Commit the generated changeset file
|
||||
|
||||
Note: Documentation-only changes do not require a changeset.`;
|
||||
|
||||
// Get existing comments
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number
|
||||
});
|
||||
|
||||
// Check if we already commented
|
||||
const botComment = comments.data.find(comment =>
|
||||
comment.user.login === 'github-actions[bot]' &&
|
||||
comment.body.includes('This PR requires a changeset')
|
||||
);
|
||||
|
||||
if (!botComment) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: message
|
||||
});
|
||||
}
|
||||
+127
-2
@@ -25,6 +25,17 @@ jobs:
|
||||
with:
|
||||
node-version: 20.15.1
|
||||
|
||||
# Setup Python for coverage script
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install requests
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
@@ -58,5 +69,119 @@ jobs:
|
||||
- name: Prettier / Format Check
|
||||
run: npm run format
|
||||
|
||||
- name: Extension Tests
|
||||
run: xvfb-run -a npm run test
|
||||
# Build the extension before running tests
|
||||
- name: Build Extension
|
||||
run: npm run compile
|
||||
|
||||
# Disabling due to compatability with test framework and ESM modules
|
||||
# - name: Unit Tests
|
||||
# run: npm run test:unit
|
||||
|
||||
# Run extension tests with coverage
|
||||
- name: Extension Tests with Coverage
|
||||
id: extension_coverage
|
||||
continue-on-error: true
|
||||
run: |
|
||||
xvfb-run -a npm run test:coverage > extension_coverage.txt 2>&1 || true
|
||||
PYTHONPATH=.github/scripts python -m coverage_check extract-coverage extension_coverage.txt --type=extension --github-output --verbose
|
||||
|
||||
# Run webview tests with coverage
|
||||
- name: Webview Tests with Coverage
|
||||
id: webview_coverage
|
||||
continue-on-error: true
|
||||
run: |
|
||||
cd webview-ui
|
||||
# Ensure coverage dependency is installed
|
||||
npm install --no-save @vitest/coverage-v8
|
||||
npm run test:coverage > webview_coverage.txt 2>&1 || true
|
||||
cd ..
|
||||
PYTHONPATH=.github/scripts python -m coverage_check extract-coverage webview-ui/webview_coverage.txt --type=webview --github-output --verbose
|
||||
|
||||
# Save coverage reports as artifacts (workflow-scoped)
|
||||
- name: Save Coverage Reports
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pr-coverage-reports
|
||||
path: |
|
||||
extension_coverage.txt
|
||||
webview-ui/webview_coverage.txt
|
||||
retention-period: workflow # Artifacts are automatically deleted when the workflow completes
|
||||
|
||||
coverage:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
# Only run on PRs to main branch
|
||||
if: github.event_name == 'pull_request' && github.base_ref == 'main'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Fetch all history for accurate comparison
|
||||
|
||||
# Setup Python for coverage script
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install requests
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.15.1
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
with:
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
# Build the extension before running tests
|
||||
- name: Build Extension
|
||||
run: npm run compile
|
||||
|
||||
# Download coverage artifacts from test job
|
||||
- name: Download Coverage Reports
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: pr-coverage-reports
|
||||
path: . # Download to root directory to match expected paths
|
||||
|
||||
# Process coverage workflow
|
||||
- name: Process coverage workflow
|
||||
id: coverage
|
||||
run: |
|
||||
# Extract PR number from GITHUB_REF
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed -e 's/refs\/pull\///' -e 's/\/merge//')
|
||||
|
||||
# Run the coverage workflow from root directory
|
||||
PYTHONPATH=.github/scripts python -m coverage_check process-workflow \
|
||||
--base-branch ${{ github.base_ref }} \
|
||||
--pr-number $PR_NUMBER \
|
||||
--repo $GITHUB_REPOSITORY \
|
||||
--token ${{ secrets.GITHUB_TOKEN }} \
|
||||
--verbose
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
+8
-1
@@ -9,4 +9,11 @@ tmp
|
||||
|
||||
pnpm-lock.yaml
|
||||
|
||||
.clineignore
|
||||
.clineignore
|
||||
.venv
|
||||
.actrc
|
||||
|
||||
# Ignore coverage directories and files
|
||||
coverage
|
||||
# But don't ignore the coverage scripts in .github/scripts/
|
||||
!.github/scripts/coverage/
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extension": ["ts"],
|
||||
"spec": "src/**/__tests__/*.ts",
|
||||
"require": ["ts-node/register", "source-map-support/register"],
|
||||
"recursive": true
|
||||
}
|
||||
Vendored
+13
@@ -16,6 +16,19 @@
|
||||
"IS_DEV": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Run Extension (Test Mode)",
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"args": ["--extensionDevelopmentPath=${workspaceFolder}"],
|
||||
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
|
||||
"preLaunchTask": "${defaultBuildTask}",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"IS_TEST": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+51
@@ -14,6 +14,14 @@
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "watch:test",
|
||||
"dependsOn": ["npm: build:webview:test", "npm: dev:webview", "npm: watch:tsc", "npm: watch:esbuild:test"],
|
||||
"presentation": {
|
||||
"reveal": "never"
|
||||
},
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "build:webview",
|
||||
@@ -32,6 +40,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "build:webview:test",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": true,
|
||||
"label": "npm: build:webview:test",
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "never",
|
||||
"close": true
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"IS_TEST": "true"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "dev:webview",
|
||||
@@ -77,6 +104,30 @@
|
||||
"group": "watch",
|
||||
"reveal": "never",
|
||||
"close": true
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
"IS_DEV": "true"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "watch:esbuild:test",
|
||||
"group": "build",
|
||||
"problemMatcher": "$esbuild-watch",
|
||||
"isBackground": true,
|
||||
"label": "npm: watch:esbuild:test",
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "never",
|
||||
"close": true
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"IS_TEST": "true"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+89
-8
@@ -1,5 +1,86 @@
|
||||
# Changelog
|
||||
|
||||
## [3.11.0]
|
||||
|
||||
- Redesign checkpoint UI to declutter chat view by using a subtle indicator line that expands to a popover on hover, with a new date indicator for when it was created
|
||||
- Add support for xAI's provider's Grok 3 models
|
||||
- Add more robust error tracking for users opted in to telemetry (thank you for helping us make Cline better!)
|
||||
|
||||
## [3.10.1]
|
||||
|
||||
- Add CMD+' keyboard shortcut to add selected text to Cline
|
||||
- Cline now auto focuses the text field when using 'Add to Cline' shortcut
|
||||
- Add new 'Create New Task' tool to let Cline start a new task autonomously!
|
||||
- Fix Mermaid diagram issues
|
||||
- Fix Gemini provider cost calculation to take new tiered pricing structure into account
|
||||
|
||||
## [3.10.0]
|
||||
|
||||
- Add setting to let browser tool use local Chrome via remote debugging, enabling session-based browsing. Replaces sessionless Chromium, unlocking debugging and productivity workflows tied to your real browser state.
|
||||
- Add new auto-approve option to approve _ALL_ commands (use at your own risk!)
|
||||
- Add modal in the chat area to more easily enable or disable MCP servers
|
||||
- Add drag and drop of file/folders into cline chat (Thanks eljapi!)
|
||||
- Add prompt caching for LiteLLM + Claude (Thanks sammcj!)
|
||||
- Add Improved context management
|
||||
- Fix MCP auto approve toggle issues being out of sync with settings
|
||||
|
||||
## [3.9.2]
|
||||
|
||||
- Add recommended models for Cline provider
|
||||
- Add ability to detect when user edits files manually so Cline knows to re-read, leading to reduced diff edit errors
|
||||
- Add improvements to file mention searching for faster searching
|
||||
- Add scoring logic to file mentions to sort and exlcude results based on relevance
|
||||
- Add Support for Bytedance Doubao (Thanks Tunixer!)
|
||||
- Fix to prevent duplicate BOM (Thanks bamps53!)
|
||||
|
||||
## [3.9.1]
|
||||
|
||||
- Add Gemini 2.5 Pro Preview 03-25 to Google Provider
|
||||
|
||||
## [3.9.0]
|
||||
|
||||
- Add Enable extended thinking for LiteLLM provider (Thanks @jorgegarciarey!)
|
||||
- Add a tab for configuring local MCP Servers
|
||||
- Fix issue with DeepSeek API provider token counting + context management
|
||||
- Fix issues with checkpoints hanging under certain conditions
|
||||
|
||||
## [3.8.6]
|
||||
|
||||
- Add UI for adding remote servers
|
||||
- Add Mentions Feature Guide and update related documentation
|
||||
- Fix bug where menu would open in sidebar and open tab
|
||||
- Fix issue with Cline accounts not showing user info in popout tabs
|
||||
- Fix bug where menu buttons wouldn't open view in sidebar
|
||||
|
||||
## [3.8.5]
|
||||
|
||||
- Add support for remote MCP Servers using SSE
|
||||
- Add gemini-2.5-pro-exp-03-25 to Vertex AI (thanks @arri-cc!)
|
||||
- Add access to history, mcp, and new task buttons in popout view
|
||||
- Add task feedback telemetry (thumbs up/down on task completion)
|
||||
- Add toggle disabled for remote servers
|
||||
- Move the MCP Restart and Delete buttons and add an auto-approve all toggle
|
||||
- Update Requestly UX for model selection (thanks @arafatkatze!)
|
||||
- Add escape for html content for gemini when running commands
|
||||
- Improve search and replace edit failure behaviors
|
||||
|
||||
## [3.8.4]
|
||||
|
||||
- Add Sambanova Deepseek-V3-0324
|
||||
- Add cost calculation support for LiteLLM provider
|
||||
- Fix bug where Cline would use plan_mode_response bug without response parameter
|
||||
|
||||
## [3.8.3]
|
||||
|
||||
- Add support for SambaNova QwQ-32B model
|
||||
- Add OpenAI "dynamic" model chatgpt-4o-latest
|
||||
- Add Amazon Nova models to AWS Bedrock
|
||||
- Improve file handling for NextJS folder naming (fixes issues with parentheses in folder names)
|
||||
- Add Gemini 2.5 Pro to Google AI Studio available models
|
||||
- Handle "input too large" errors for Anthropic
|
||||
- Fix "See more" not showing up for tasks after task un-fold
|
||||
- Fix gpt-4.5-preview's supportsPromptCache value to true
|
||||
|
||||
## [3.8.2]
|
||||
|
||||
- Fix bug where switching to plan/act would result in VS Code LM/OpenRouter model being reset
|
||||
@@ -7,12 +88,12 @@
|
||||
## [3.8.0]
|
||||
|
||||
- Add 'Add to Cline' as an option when you right-click in a file or the terminal, making it easier to add context to your current task
|
||||
- Add 'Fix with Cline' code action - when you see a lightbulb icon in your editor, you can now select 'Fix with Cline' to send the code and associated errors for Cline to fix. (Cursor users can also use the 'Quick Fix (CMD + .)' menu to see this option)
|
||||
- Add 'Fix with Cline' code action - when you see a lightbulb icon in your editor, you can now select 'Fix with Cline' to send the code and associated errors for Cline to fix. (Cursor users can also use the 'Quick Fix (CMD + .)' menu to see this option)
|
||||
- Add Account view to display billing and usage history for Cline account users. You can now keep track of credits used and transaction history right in the extension!
|
||||
- Add 'Sort underling provider routing' setting to Cline/OpenRouter allowing you to sort provider used by throughput, price, latency, or the default (combination of price and uptime)
|
||||
- Improve rich MCP display with dynamic image loading and support for GIFs
|
||||
- Add 'Documentation' menu item to easily access Cline's docs
|
||||
- Add OpenRouter's new usage_details feature for more reliable cost reporting
|
||||
- Add OpenRouter's new usage_details feature for more reliable cost reporting
|
||||
- Display total space Cline takes on disk next to 'Delete all Tasks' button in History view
|
||||
- Fix 'Context Window Exceeded' error for OpenRouter/Cline Accounts (additional support coming soon)
|
||||
- Fix bug where OpenRouter model ID would be set to invalid value
|
||||
@@ -193,8 +274,8 @@
|
||||
## [3.1.0]
|
||||
|
||||
- Added checkpoints: Snapshots of workspace are automatically created whenever Cline uses a tool
|
||||
- Compare changes: Hover over any tool use to see a diff between the snapshot and current workspace state
|
||||
- Restore options: Choose to restore just the task state, just the workspace files, or both
|
||||
- Compare changes: Hover over any tool use to see a diff between the snapshot and current workspace state
|
||||
- Restore options: Choose to restore just the task state, just the workspace files, or both
|
||||
- New 'See new changes' button appears after task completion, providing an overview of all workspace changes
|
||||
- Task header now shows disk space usage with a delete button to help manage snapshot storage
|
||||
|
||||
@@ -376,10 +457,10 @@
|
||||
## [1.8.0]
|
||||
|
||||
- You can now use '@' in the textarea to add context!
|
||||
- @url: Paste in a URL for the extension to fetch and convert to markdown, useful when you want to give Claude the latest docs!
|
||||
- @problems: Add workspace errors and warnings for Claude to fix, no more back-and-forth about debugging
|
||||
- @file: Adds a file's contents so you don't have to waste API requests approving read file (+ type to search files)
|
||||
- @folder: Adds folder's files all at once to speed up your workflow even more
|
||||
- @url: Paste in a URL for the extension to fetch and convert to markdown, useful when you want to give Claude the latest docs!
|
||||
- @problems: Add workspace errors and warnings for Claude to fix, no more back-and-forth about debugging
|
||||
- @file: Adds a file's contents so you don't have to waste API requests approving read file (+ type to search files)
|
||||
- @folder: Adds folder's files all at once to speed up your workflow even more
|
||||
|
||||
## [1.7.0]
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md
|
||||
<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>Feature Requests</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://docs.cline.bot/getting-started/getting-started-new-coders" target="_blank"><strong>Getting Started</strong></a>
|
||||
<a href="https://docs.cline.bot/getting-started/for-new-coders" target="_blank"><strong>Getting Started</strong></a>
|
||||
</td>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -18,6 +18,7 @@ Welcome to the Cline documentation - your comprehensive guide to using and exten
|
||||
- **Understand Cline's capabilities:**
|
||||
|
||||
- [Cline Tools Guide](tools/cline-tools-guide.md)
|
||||
- [Mentions Feature Guide](tools/mentions-guide.md)
|
||||
|
||||
- **Extend Cline with MCP Servers:**
|
||||
- [MCP Overview](mcp/README.md)
|
||||
|
||||
@@ -1,41 +1,67 @@
|
||||
graph TB
|
||||
subgraph VSCode Extension Host
|
||||
subgraph Core Extension
|
||||
ExtensionEntry[Extension Entry<br/>src/extension.ts]
|
||||
ClineProvider[ClineProvider<br/>src/core/webview/ClineProvider.ts]
|
||||
ClineClass[Cline Class<br/>src/core/Cline.ts]
|
||||
GlobalState[VSCode Global State]
|
||||
SecretsStorage[VSCode Secrets Storage]
|
||||
flowchart TB
|
||||
subgraph "VSCode Extension Host"
|
||||
subgraph "Core Extension"
|
||||
ExtensionEntry["Extension Entry<br/>src/extension.ts"]
|
||||
WebviewProvider["WebviewProvider<br/>src/core/webview/index.ts"]
|
||||
Controller["Controller<br/>src/core/controller/index.ts"]
|
||||
Task["Task<br/>src/core/task/index.ts"]
|
||||
GlobalState["VSCode Global State"]
|
||||
SecretsStorage["VSCode Secrets Storage"]
|
||||
McpHub["McpHub<br/>src/services/mcp/McpHub.ts"]
|
||||
end
|
||||
|
||||
subgraph Webview UI
|
||||
WebviewApp[React App<br/>webview-ui/src/App.tsx]
|
||||
ExtStateContext[ExtensionStateContext<br/>webview-ui/src/context/ExtensionStateContext.tsx]
|
||||
ReactComponents[React Components]
|
||||
subgraph "Webview UI"
|
||||
WebviewApp["React App<br/>webview-ui/src/App.tsx"]
|
||||
ExtStateContext["ExtensionStateContext<br/>webview-ui/src/context/ExtensionStateContext.tsx"]
|
||||
ReactComponents["React Components"]
|
||||
end
|
||||
|
||||
subgraph Storage
|
||||
TaskStorage[Task Storage<br/>Per-Task Files & History]
|
||||
CheckpointSystem[Git-based Checkpoints]
|
||||
subgraph "Storage"
|
||||
TaskStorage["Task Storage<br/>Per-Task Files & History"]
|
||||
CheckpointSystem["Git-based Checkpoints"]
|
||||
end
|
||||
|
||||
subgraph "API Providers"
|
||||
AnthropicAPI["Anthropic"]
|
||||
OpenRouterAPI["OpenRouter"]
|
||||
BedrockAPI["AWS Bedrock"]
|
||||
OtherAPIs["Other Providers"]
|
||||
end
|
||||
|
||||
subgraph "MCP Servers"
|
||||
ExternalMcpServers["External MCP Servers"]
|
||||
end
|
||||
end
|
||||
|
||||
%% Core Extension Data Flow
|
||||
ExtensionEntry --> ClineProvider
|
||||
ClineProvider --> ClineClass
|
||||
ClineClass --> GlobalState
|
||||
ClineClass --> SecretsStorage
|
||||
ClineClass --> TaskStorage
|
||||
ClineClass --> CheckpointSystem
|
||||
ExtensionEntry --> WebviewProvider
|
||||
WebviewProvider --> Controller
|
||||
Controller --> Task
|
||||
Controller --> McpHub
|
||||
Task --> GlobalState
|
||||
Task --> SecretsStorage
|
||||
Task --> TaskStorage
|
||||
Task --> CheckpointSystem
|
||||
Task --> |"API Requests"| AnthropicAPI
|
||||
Task --> |"API Requests"| OpenRouterAPI
|
||||
Task --> |"API Requests"| BedrockAPI
|
||||
Task --> |"API Requests"| OtherAPIs
|
||||
McpHub --> |"Connects to"| ExternalMcpServers
|
||||
Task --> |"Uses"| McpHub
|
||||
|
||||
%% Webview Data Flow
|
||||
WebviewApp --> ExtStateContext
|
||||
ExtStateContext --> ReactComponents
|
||||
|
||||
%% Bidirectional Communication
|
||||
ClineProvider <-->|postMessage| ExtStateContext
|
||||
WebviewProvider <--> |"postMessage"| ExtStateContext
|
||||
|
||||
style GlobalState fill:#ff0066,stroke:#333,stroke-width:2px,color:#ffffff
|
||||
style SecretsStorage fill:#ff0066,stroke:#333,stroke-width:2px,color:#ffffff
|
||||
style ExtStateContext fill:#0066ff,stroke:#333,stroke-width:2px,color:#ffffff
|
||||
style ClineProvider fill:#00cc66,stroke:#333,stroke-width:2px,color:#ffffff
|
||||
classDef vscodeState fill:#f9f,stroke:#333,stroke-width:2px
|
||||
classDef contextClass fill:#bbf,stroke:#333,stroke-width:2px
|
||||
classDef providerClass fill:#bfb,stroke:#333,stroke-width:2px
|
||||
classDef apiClass fill:#fdb,stroke:#333,stroke-width:2px
|
||||
|
||||
class GlobalState,SecretsStorage vscodeState
|
||||
class ExtStateContext contextClass
|
||||
class WebviewProvider,McpHub providerClass
|
||||
class AnthropicAPI,OpenRouterAPI,BedrockAPI,OtherAPIs apiClass
|
||||
|
||||
@@ -28,7 +28,7 @@ There are multiple places online to find MCP servers:
|
||||
2. **Example Interaction with Cline:**
|
||||
|
||||
```
|
||||
User: "Cline, I want to add the MCP server for Brave browser control. Here's the GitHub link: https://github.com/modelcontextprotocol/servers/tree/main/src/brave Can you add it?"
|
||||
User: "Cline, I want to add the MCP server for Brave browser control. Here's the GitHub link: https://github.com/modelcontextprotocol/servers/tree/main/src/brave-search Can you add it?"
|
||||
|
||||
Cline: "OK. Cloning the repository to the MCP directory. It needs to be built because it has a 'package.json' file. Should I run 'npm run build'?"
|
||||
|
||||
|
||||
@@ -20,8 +20,9 @@ Cline is your AI assistant that can:
|
||||
|
||||
2. **Provide Context**
|
||||
|
||||
- Use @ mentions to add files, folders, or URLs
|
||||
- Example: "@file:src/components/App.tsx"
|
||||
- Use @ mentions to add files, folders, URLs, diagnostics, terminal output, and more
|
||||
- Example: "@/src/components/App.tsx"
|
||||
- See the [Mentions Feature Guide](./mentions-guide.md) for details
|
||||
|
||||
3. **Review Changes**
|
||||
- Cline will show diffs before making changes
|
||||
@@ -54,7 +55,7 @@ Cline is your AI assistant that can:
|
||||
|
||||
## Available Tools
|
||||
|
||||
For the most up-to-date implementation details, you can view the full source code in the [Cline repository](https://github.com/cline/cline/blob/main/src/core/Cline.ts).
|
||||
For the most up-to-date implementation details, you can view the full source code in the [Cline repository](https://github.com/cline/cline/blob/main/src/core/task/index.ts).
|
||||
|
||||
Cline has access to the following tools for various tasks:
|
||||
|
||||
@@ -81,6 +82,7 @@ Cline has access to the following tools for various tasks:
|
||||
4. **Interaction Tools**
|
||||
- `ask_followup_question`: Ask user for clarification
|
||||
- `attempt_completion`: Present final results
|
||||
- `new_task`: Start a new task with preloaded context
|
||||
|
||||
Each tool has specific parameters and usage patterns. Here are some examples:
|
||||
|
||||
@@ -113,6 +115,21 @@ Each tool has specific parameters and usage patterns. Here are some examples:
|
||||
</execute_command>
|
||||
```
|
||||
|
||||
- Start a new task with context (new_task):
|
||||
```xml
|
||||
<new_task>
|
||||
<context>
|
||||
We've completed the backend API with these endpoints:
|
||||
- GET /api/tasks
|
||||
- POST /api/tasks
|
||||
- PUT /api/tasks/:id
|
||||
- DELETE /api/tasks/:id
|
||||
|
||||
Now we need to implement the React frontend.
|
||||
</context>
|
||||
</new_task>
|
||||
```
|
||||
|
||||
## Common Tasks
|
||||
|
||||
1. **Create a New Component**
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
# Cline Mentions Feature Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The mentions feature is a powerful capability that allows you to reference various resources in your conversations with Cline using the "@" symbol. This includes file contents, directory structures, webpage URLs, VSCode diagnostic information, terminal output, Git change status, and more - all easily incorporated into your conversations.
|
||||
|
||||
By using this feature, Cline can gain more accurate context and provide more relevant assistance for your tasks.
|
||||
|
||||
## Basic Syntax
|
||||
|
||||
Mentions always start with the "@" symbol, followed by the path or identifier of the resource you want to reference:
|
||||
|
||||
```
|
||||
@resource_identifier
|
||||
```
|
||||
|
||||
You can place mentions anywhere in your user messages, and Cline will automatically retrieve the referenced content.
|
||||
|
||||
## Supported Mention Types
|
||||
|
||||
### 1. File References
|
||||
|
||||
To reference file contents, use `@/` followed by the relative path within your project:
|
||||
|
||||
```
|
||||
@/path/to/file.js
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Please analyze the implementation in @/src/components/Button.tsx
|
||||
```
|
||||
|
||||
In this example, Cline automatically retrieves the contents of Button.tsx and uses it to perform the analysis.
|
||||
|
||||
### 2. Directory References
|
||||
|
||||
To reference directory contents, use `@/` followed by the relative path of the directory, ending with a trailing `/`:
|
||||
|
||||
```
|
||||
@/path/to/directory/
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```
|
||||
What components are available in the @/src/components/ directory?
|
||||
```
|
||||
|
||||
In this example, Cline retrieves a listing of the components directory and its contents.
|
||||
|
||||
### 3. URL References
|
||||
|
||||
To reference web page contents, use `@` followed by the URL:
|
||||
|
||||
```
|
||||
@https://example.com
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Please parse the JSON response from @https://api.github.com/users/octocat
|
||||
```
|
||||
|
||||
In this example, Cline fetches the response from the GitHub API and analyzes the JSON.
|
||||
|
||||
### 4. Diagnostic References
|
||||
|
||||
To reference VSCode diagnostic information (errors and warnings) in the current workspace, use `@problems`:
|
||||
|
||||
```
|
||||
@problems
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Check @problems and tell me which errors I should prioritize fixing
|
||||
```
|
||||
|
||||
In this example, Cline retrieves the current errors and warnings from your workspace and identifies high-priority issues.
|
||||
|
||||
### 5. Terminal Output References
|
||||
|
||||
To reference the latest terminal output, use `@terminal`:
|
||||
|
||||
```
|
||||
@terminal
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Please identify the cause of the error in the @terminal output
|
||||
```
|
||||
|
||||
In this example, Cline examines the latest terminal output and analyzes the error's cause.
|
||||
|
||||
### 6. Git Working Directory References
|
||||
|
||||
To reference the current Git working directory change status, use `@git-changes`:
|
||||
|
||||
```
|
||||
@git-changes
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Review the @git-changes and summarize the important changes that should be committed
|
||||
```
|
||||
|
||||
In this example, Cline retrieves the list of changed files in the current Git working directory and identifies candidates for commit.
|
||||
|
||||
### 7. Git Commit References
|
||||
|
||||
To reference information about a specific Git commit, use `@` followed by the commit hash:
|
||||
|
||||
```
|
||||
@commit_hash
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Analyze the commit @abcd123 and explain what changes were made
|
||||
```
|
||||
|
||||
In this example, Cline retrieves information about the specified commit hash and analyzes the changes made in that commit.
|
||||
|
||||
## Usage Scenarios
|
||||
|
||||
### Code Review
|
||||
|
||||
```
|
||||
Check @/src/components/Form.jsx and suggest improvements from a performance perspective. Also, if there are any @problems, please suggest how to fix them.
|
||||
```
|
||||
|
||||
### Debugging Assistance
|
||||
|
||||
```
|
||||
My npm install failed. Please examine the @terminal output and suggest a solution to the problem.
|
||||
```
|
||||
|
||||
### Project Analysis
|
||||
|
||||
```
|
||||
Analyze the code in the @/src/models/ directory and explain the relationships between the data models. Also, tell me how the utility functions in @/src/utils/ are used with these models.
|
||||
```
|
||||
|
||||
### Code Generation
|
||||
|
||||
```
|
||||
Create a new Input.tsx component using the same design language as @/src/components/Button.tsx
|
||||
```
|
||||
|
||||
### Version Control Integration
|
||||
|
||||
```
|
||||
Review the @git-changes and suggest a commit message for the feature I'm working on.
|
||||
```
|
||||
|
||||
## Combining Multiple Mentions
|
||||
|
||||
You can combine multiple mentions to provide more complex context:
|
||||
|
||||
```
|
||||
There seems to be a bug in @/src/api/users.js. Please check @problems and @terminal to identify and fix the issue.
|
||||
```
|
||||
|
||||
## Limitations and Considerations
|
||||
|
||||
1. **Large Files**: Referencing very large files may take time to process and could consume a significant amount of tokens.
|
||||
|
||||
2. **Binary Files**: Binary files (such as images) will not be properly processed and will show a "Binary file" message.
|
||||
|
||||
3. **Directory Structure**: Directory references will only show top-level files and directories, not recursively showing the contents of subdirectories.
|
||||
|
||||
4. **URL Limitations**: Some websites may block automated crawling, which could prevent accurate content retrieval.
|
||||
|
||||
5. **Path Syntax**: File paths or URLs with special characters (such as spaces) may not be recognized correctly.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Mentions Not Recognized
|
||||
|
||||
If your mentions aren't being recognized correctly, check that:
|
||||
|
||||
- There's no space after the `@` symbol
|
||||
- File paths are accurate (case-sensitive)
|
||||
- URLs include the full format (with `https://`)
|
||||
|
||||
### Content Not Retrieved
|
||||
|
||||
If the content of referenced resources can't be retrieved:
|
||||
|
||||
- Verify the file exists
|
||||
- Ensure you have access permissions for the file
|
||||
- Check that the file isn't too large or the URL too complex
|
||||
|
||||
### Performance Issues
|
||||
|
||||
If mention processing is slow:
|
||||
|
||||
- Reference smaller files or specific file sections
|
||||
- Reduce the number of mentions used at once
|
||||
|
||||
## Conclusion
|
||||
|
||||
Mastering the mentions feature makes your communication with Cline more efficient. By providing appropriate context, Cline can deliver more accurate assistance, significantly improving your development workflow.
|
||||
+13
@@ -4,6 +4,7 @@ const path = require("path")
|
||||
|
||||
const production = process.argv.includes("--production")
|
||||
const watch = process.argv.includes("--watch")
|
||||
const test = process.env.IS_TEST === "true"
|
||||
|
||||
/**
|
||||
* @type {import('esbuild').Plugin}
|
||||
@@ -68,10 +69,22 @@ const extensionConfig = {
|
||||
minify: production,
|
||||
sourcemap: !production,
|
||||
logLevel: "silent",
|
||||
define: {
|
||||
"process.env.IS_DEV": JSON.stringify(!production),
|
||||
"process.env.IS_TEST": JSON.stringify(test),
|
||||
},
|
||||
plugins: [
|
||||
copyWasmFiles,
|
||||
/* add to the end of plugins array */
|
||||
esbuildProblemMatcherPlugin,
|
||||
{
|
||||
name: "alias-plugin",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^pkce-challenge$/ }, (args) => {
|
||||
return { path: require.resolve("pkce-challenge/dist/index.browser.js") }
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
entryPoints: ["src/extension.ts"],
|
||||
format: "cjs",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Cline – OpenRouter 排名第一
|
||||
# Cline – OpenRouter 排名第一的 AI 工具
|
||||
|
||||
<p align="center">
|
||||
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
|
||||
@@ -20,7 +20,7 @@
|
||||
<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>功能请求</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://cline.bot/join-us" target="_blank"><strong>我们正在招聘!</strong></a>
|
||||
<a href="https://docs.cline.bot/getting-started/for-new-coders" target="_blank"><strong>新手上路</strong></a>
|
||||
</td>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -33,12 +33,12 @@
|
||||
1. 输入你的任务并添加图像,将模型转换为功能应用程序或通过截图修复错误。
|
||||
2. Cline 首先分析你的文件结构和源代码 AST,运行正则表达式搜索,并阅读相关文件以了解现有项目。通过仔细管理添加到上下文中的信息,Cline 即使在大型复杂项目中也能提供有价值的帮助,而不会使上下文窗口过载。
|
||||
3. 一旦 Cline 获得所需信息,他可以:
|
||||
- 创建和编辑文件 + 监控 linter/编译器错误,从而主动修复诸如缺少导入和语法错误等问题。
|
||||
- 直接在你的终端中执行命令并监控其输出,从而在编辑文件后对开发服务器问题做出反应。
|
||||
- 对于 Web 开发任务,Cline 可以在无头浏览器中启动网站,点击、输入、滚动并捕获截图和控制台日志,从而修复运行时错误和视觉错误。
|
||||
- 创建和编辑文件 + 监控 linter/编译器错误,从而主动修复诸如缺少导入和语法错误等问题。
|
||||
- 直接在你的终端中执行命令并监控其输出,从而在编辑文件后对开发服务器问题做出反应。
|
||||
- 对于 Web 开发任务,Cline 可以在无头浏览器中启动网站,点击、输入、滚动并捕获截图和控制台日志,从而修复运行时错误和视觉错误。
|
||||
4. 当任务完成时,Cline 将通过终端命令如 `open -a "Google Chrome" index.html` 向你展示结果,你可以通过点击按钮运行该命令。
|
||||
|
||||
> [!提示]
|
||||
> [!TIP]
|
||||
> 使用 `CMD/CTRL + Shift + P` 快捷键打开命令面板并输入 "Cline: Open In New Tab" 将扩展作为标签在编辑器中打开。这让你可以与文件资源管理器并排使用 Cline,更清楚地看到他如何改变你的工作空间。
|
||||
|
||||
---
|
||||
|
||||
Generated
+453
-51
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.8.0",
|
||||
"version": "3.10.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.8.0",
|
||||
"version": "3.10.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/bedrock-sdk": "^0.12.4",
|
||||
@@ -23,20 +23,19 @@
|
||||
"@opentelemetry/sdk-node": "^0.39.1",
|
||||
"@opentelemetry/sdk-trace-node": "^1.30.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.30.0",
|
||||
"@types/clone-deep": "^4.0.4",
|
||||
"@types/get-folder-size": "^3.0.4",
|
||||
"@types/pdf-parse": "^1.1.4",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"@sentry/browser": "^9.12.0",
|
||||
"@vscode/codicons": "^0.0.36",
|
||||
"axios": "^1.8.2",
|
||||
"cheerio": "^1.0.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"chrome-launcher": "^1.1.2",
|
||||
"clone-deep": "^4.0.1",
|
||||
"default-shell": "^2.2.0",
|
||||
"diff": "^5.2.0",
|
||||
"execa": "^9.5.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"firebase": "^11.2.0",
|
||||
"fzf": "^0.5.2",
|
||||
"get-folder-size": "^5.0.0",
|
||||
"globby": "^14.0.2",
|
||||
"ignore": "^7.0.3",
|
||||
@@ -64,11 +63,16 @@
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.27.12",
|
||||
"@types/chai": "^5.0.1",
|
||||
"@types/clone-deep": "^4.0.4",
|
||||
"@types/diff": "^5.2.1",
|
||||
"@types/get-folder-size": "^3.0.4",
|
||||
"@types/mocha": "^10.0.7",
|
||||
"@types/node": "20.x",
|
||||
"@types/pdf-parse": "^1.1.4",
|
||||
"@types/proxyquire": "^1.3.31",
|
||||
"@types/should": "^11.2.0",
|
||||
"@types/sinon": "^17.0.4",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"@types/vscode": "^1.84.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.14.1",
|
||||
"@typescript-eslint/parser": "^7.11.0",
|
||||
@@ -80,8 +84,10 @@
|
||||
"husky": "^9.1.7",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^3.3.3",
|
||||
"proxyquire": "^2.1.3",
|
||||
"should": "^13.2.3",
|
||||
"sinon": "^19.0.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"engines": {
|
||||
@@ -4286,6 +4292,30 @@
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
|
||||
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.0.3",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz",
|
||||
@@ -6827,6 +6857,81 @@
|
||||
"integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sentry-internal/browser-utils": {
|
||||
"version": "9.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.12.0.tgz",
|
||||
"integrity": "sha512-GXuDEG2Ix8DmVtTkjsItWdusk2CvJ6EPWKYVqFKifxt+IAT3ZbhGZd99Rg3wdRmt9xhCNuS4QrDzDTPMPgfdCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "9.12.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry-internal/feedback": {
|
||||
"version": "9.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.12.0.tgz",
|
||||
"integrity": "sha512-3+UxoT97QIXNSUQS4ATL1FFws0RkUb6PeaQN8CPndI6mFlqTW5tuVVLNg9Eo1seNg7R/dfk6WHCWrYN1NbFFKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "9.12.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry-internal/replay": {
|
||||
"version": "9.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.12.0.tgz",
|
||||
"integrity": "sha512-njEQosFeO/UX+gG+DMRANkPUuz6OIJLb+A1GVylhq9adUgFQydQ9Ay3v7/x1gMhdfHVP6Jeb27qkti0BWYbzBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry-internal/browser-utils": "9.12.0",
|
||||
"@sentry/core": "9.12.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry-internal/replay-canvas": {
|
||||
"version": "9.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.12.0.tgz",
|
||||
"integrity": "sha512-p8LuKZgWT/CoQBbDOXkSGjWWnc8WsnAayWgna8M/ZFWNITCNEM2rCuqZOyWOElIlrni+M7qoEA3jS7MZe8Ejxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry-internal/replay": "9.12.0",
|
||||
"@sentry/core": "9.12.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/browser": {
|
||||
"version": "9.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.12.0.tgz",
|
||||
"integrity": "sha512-4xQYoZqi+VVhNvlhWiwRd57+SMr3Og4sLjuayAA+zIp1Wx/bDcIld697cugLwml/BR+mVJI2eokkgh1CBl6zag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry-internal/browser-utils": "9.12.0",
|
||||
"@sentry-internal/feedback": "9.12.0",
|
||||
"@sentry-internal/replay": "9.12.0",
|
||||
"@sentry-internal/replay-canvas": "9.12.0",
|
||||
"@sentry/core": "9.12.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/core": {
|
||||
"version": "9.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.12.0.tgz",
|
||||
"integrity": "sha512-jOqQK/90uzHmsBvkPTj/DAEFvA5poX4ZRyC7LE1zjg4F5jdOp3+M4W3qCy0CkSTu88Zu5VWBoppCU2Bs34XEqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sindresorhus/merge-streams": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
|
||||
@@ -8569,6 +8674,34 @@
|
||||
"integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node10": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
|
||||
"integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node12": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
|
||||
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node14": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
|
||||
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node16": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
|
||||
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.0.1.tgz",
|
||||
@@ -8581,7 +8714,9 @@
|
||||
"node_modules/@types/clone-deep": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/clone-deep/-/clone-deep-4.0.4.tgz",
|
||||
"integrity": "sha512-vXh6JuuaAha6sqEbJueYdh5zNBPPgG1OYumuz2UvLvriN6ABHDSW8ludREGWJb1MLIzbwZn4q4zUbUCerJTJfA=="
|
||||
"integrity": "sha512-vXh6JuuaAha6sqEbJueYdh5zNBPPgG1OYumuz2UvLvriN6ABHDSW8ludREGWJb1MLIzbwZn4q4zUbUCerJTJfA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
@@ -8600,6 +8735,7 @@
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/get-folder-size/-/get-folder-size-3.0.4.tgz",
|
||||
"integrity": "sha512-tSf/k7Undx6jKRwpChR9tl+0ZPf0BVwkjBRtJ5qSnz6iWm2ZRYMAS2MktC2u7YaTAFHmxpL/LBxI85M7ioJCSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
@@ -8642,6 +8778,14 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pdf-parse/-/pdf-parse-1.1.4.tgz",
|
||||
"integrity": "sha512-+gbBHbNCVGGYw1S9lAIIvrHW47UYOhMIFUsJcMkMrzy1Jf0vulBN3XQIjPgnoOXveMuHnF3b57fXROnY/Or7eg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/proxyquire": {
|
||||
"version": "1.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/proxyquire/-/proxyquire-1.3.31.tgz",
|
||||
"integrity": "sha512-uALowNG2TSM1HNPMMOR0AJwv4aPYPhqB0xlEhkeRTMuto5hjoSPZkvgu1nbPUkz3gEPAHv4sy4DmKsurZiEfRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/should": {
|
||||
@@ -8672,6 +8816,7 @@
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.5.tgz",
|
||||
"integrity": "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
@@ -9107,6 +9252,19 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-walk": {
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
|
||||
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz",
|
||||
@@ -9210,6 +9368,13 @@
|
||||
"integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
@@ -9340,50 +9505,75 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bare-events": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.4.2.tgz",
|
||||
"integrity": "sha512-qMKFd2qG/36aA4GwvKq8MxnPgCQAmBWmSyLWsJcbn8v03wvIPQ/hG1Ms8bPzndZxMDoHpxez5VOS+gC9Yi24/Q==",
|
||||
"version": "2.5.4",
|
||||
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz",
|
||||
"integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/bare-fs": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.3.5.tgz",
|
||||
"integrity": "sha512-SlE9eTxifPDJrT6YgemQ1WGFleevzwY+XAP1Xqgl56HtcrisC2CHCZ2tq6dBpcH2TnNxwUEUGhweo+lrQtYuiw==",
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.0.2.tgz",
|
||||
"integrity": "sha512-S5mmkMesiduMqnz51Bfh0Et9EX0aTCJxhsI4bvzFFLs8Z1AV8RDHadfY5CyLwdoLHgXbNBEN1gQcbEtGwuvixw==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"bare-events": "^2.0.0",
|
||||
"bare-path": "^2.0.0",
|
||||
"bare-stream": "^2.0.0"
|
||||
"bare-events": "^2.5.4",
|
||||
"bare-path": "^3.0.0",
|
||||
"bare-stream": "^2.6.4"
|
||||
},
|
||||
"engines": {
|
||||
"bare": ">=1.16.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bare-buffer": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bare-buffer": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/bare-os": {
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.4.4.tgz",
|
||||
"integrity": "sha512-z3UiI2yi1mK0sXeRdc4O1Kk8aOa/e+FNWZcTiPB/dfTWyLypuE99LibgRaQki914Jq//yAWylcAt+mknKdixRQ==",
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz",
|
||||
"integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"bare": ">=1.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bare-path": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-2.1.3.tgz",
|
||||
"integrity": "sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA==",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
|
||||
"integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"bare-os": "^2.1.0"
|
||||
"bare-os": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/bare-stream": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.3.0.tgz",
|
||||
"integrity": "sha512-pVRWciewGUeCyKEuRxwv06M079r+fRjAQjBEK2P6OYGrO43O+Z0LrPZZEjlc4mB6C2RpZ9AxJ1s7NLEtOHO6eA==",
|
||||
"version": "2.6.5",
|
||||
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz",
|
||||
"integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"b4a": "^1.6.6",
|
||||
"streamx": "^2.20.0"
|
||||
"streamx": "^2.21.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bare-buffer": "*",
|
||||
"bare-events": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bare-buffer": {
|
||||
"optional": true
|
||||
},
|
||||
"bare-events": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
@@ -9853,6 +10043,24 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/chrome-launcher": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-1.1.2.tgz",
|
||||
"integrity": "sha512-YclTJey34KUm5jB1aEJCq807bSievi7Nb/TU4Gu504fUYi3jw3KCIaH6L7nFWQhdEgH3V+wCh+kKD1P5cXnfxw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"is-wsl": "^2.2.0",
|
||||
"lighthouse-logger": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"print-chrome-path": "bin/print-chrome-path.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chromium-bidi": {
|
||||
"version": "0.6.5",
|
||||
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.6.5.tgz",
|
||||
@@ -10143,6 +10351,13 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/create-require": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -10833,9 +11048,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz",
|
||||
"integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==",
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -10851,7 +11066,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -11560,6 +11774,30 @@
|
||||
"node": "^10.12.0 || >=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-keys": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz",
|
||||
"integrity": "sha512-tcgI872xXjwFF4xgQmLxi76GnwJG3g/3isB1l4/G5Z4zrbddGpBjqZCO9oEAcB5wX0Hj/5iQB3toxfO7in1hHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-object": "~1.0.1",
|
||||
"merge-descriptors": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-keys/node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -11853,6 +12091,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/fzf": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fzf/-/fzf-0.5.2.tgz",
|
||||
"integrity": "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/gauge": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/gauge/-/gauge-5.0.2.tgz",
|
||||
@@ -12747,6 +12991,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-docker": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
|
||||
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"is-docker": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -12828,6 +13087,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-object": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz",
|
||||
"integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-path-inside": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
|
||||
@@ -13007,6 +13276,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-wsl": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
|
||||
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-docker": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
@@ -13278,6 +13559,31 @@
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lighthouse-logger": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-2.0.1.tgz",
|
||||
"integrity": "sha512-ioBrW3s2i97noEmnXxmUq7cjIcVRjT5HBpAYy8zE11CxU9HqlWHHeRxfeN1tn8F7OEMVPIC9x1f8t3Z7US9ehQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"debug": "^2.6.9",
|
||||
"marky": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lighthouse-logger/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lighthouse-logger/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/load-json-file": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz",
|
||||
@@ -13416,6 +13722,13 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/make-error": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/mammoth": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.8.0.tgz",
|
||||
@@ -13449,6 +13762,12 @@
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/marky": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/marky/-/marky-1.2.5.tgz",
|
||||
"integrity": "sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -13821,6 +14140,13 @@
|
||||
"integrity": "sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/module-not-found-error": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz",
|
||||
"integrity": "sha512-pEk4ECWQXV6z2zjhRZUongnLJNUeGQJ3w6OQ5ctGwD+i5o93qjRQUk2Rt6VdNeu3sEP0AB4LcfvdebpxBRVr4g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/monaco-vscode-textmate-theme-converter": {
|
||||
"version": "0.1.7",
|
||||
"resolved": "https://registry.npmjs.org/monaco-vscode-textmate-theme-converter/-/monaco-vscode-textmate-theme-converter-0.1.7.tgz",
|
||||
@@ -15086,6 +15412,18 @@
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
|
||||
},
|
||||
"node_modules/proxyquire": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.3.tgz",
|
||||
"integrity": "sha512-BQWfCqYM+QINd+yawJz23tbBM40VIGXOdDw3X344KcclI/gtBbdWF6SlQ4nK/bYhF9d27KYug9WzljHC6B9Ysg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-keys": "^1.0.2",
|
||||
"module-not-found-error": "^1.0.1",
|
||||
"resolve": "^1.11.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz",
|
||||
@@ -15171,12 +15509,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/queue-tick": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz",
|
||||
"integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/randombytes": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||
@@ -15584,9 +15916,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.6.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
|
||||
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
|
||||
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -16113,13 +16445,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/streamx": {
|
||||
"version": "2.20.1",
|
||||
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.20.1.tgz",
|
||||
"integrity": "sha512-uTa0mU6WUC65iUvzKH4X9hEdvSW7rbPxPtwfWiLMSj3qTdQbAiUboZTxauKfpFuGIGa1C2BYijZ7wgdUXICJhA==",
|
||||
"version": "2.22.0",
|
||||
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz",
|
||||
"integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-fifo": "^1.3.2",
|
||||
"queue-tick": "^1.0.1",
|
||||
"text-decoder": "^1.1.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
@@ -16382,17 +16713,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.6.tgz",
|
||||
"integrity": "sha512-iokBDQQkUyeXhgPYaZxmczGPhnhXZ0CmrqI+MOb/WFGS9DW5wnfrLgtjUJBvz50vQ3qfRwJ62QVoCFu8mPVu5w==",
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz",
|
||||
"integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^3.1.5"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"bare-fs": "^2.1.1",
|
||||
"bare-path": "^2.1.0"
|
||||
"bare-fs": "^4.0.1",
|
||||
"bare-path": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
@@ -16585,6 +16916,60 @@
|
||||
"typescript": ">=4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-node": {
|
||||
"version": "10.9.2",
|
||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
"@tsconfig/node12": "^1.0.7",
|
||||
"@tsconfig/node14": "^1.0.0",
|
||||
"@tsconfig/node16": "^1.0.2",
|
||||
"acorn": "^8.4.1",
|
||||
"acorn-walk": "^8.1.1",
|
||||
"arg": "^4.1.0",
|
||||
"create-require": "^1.1.0",
|
||||
"diff": "^4.0.1",
|
||||
"make-error": "^1.1.1",
|
||||
"v8-compile-cache-lib": "^3.0.1",
|
||||
"yn": "3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"ts-node": "dist/bin.js",
|
||||
"ts-node-cwd": "dist/bin-cwd.js",
|
||||
"ts-node-esm": "dist/bin-esm.js",
|
||||
"ts-node-script": "dist/bin-script.js",
|
||||
"ts-node-transpile-only": "dist/bin-transpile.js",
|
||||
"ts-script": "dist/bin-script-deprecated.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@swc/core": ">=1.2.50",
|
||||
"@swc/wasm": ">=1.2.50",
|
||||
"@types/node": "*",
|
||||
"typescript": ">=2.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@swc/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@swc/wasm": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ts-node/node_modules/diff": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
|
||||
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
@@ -16909,6 +17294,13 @@
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/v8-to-istanbul": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
||||
@@ -17463,6 +17855,16 @@
|
||||
"fd-slicer": "~1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yn": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
+60
-15
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.8.2",
|
||||
"version": "3.11.1",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
@@ -102,11 +102,6 @@
|
||||
"category": "Cline",
|
||||
"when": "cline.isDevMode"
|
||||
},
|
||||
{
|
||||
"command": "cline.openDocumentation",
|
||||
"title": "Documentation",
|
||||
"icon": "$(book)"
|
||||
},
|
||||
{
|
||||
"command": "cline.addToChat",
|
||||
"title": "Add to Cline",
|
||||
@@ -123,6 +118,16 @@
|
||||
"category": "Cline"
|
||||
}
|
||||
],
|
||||
"keybindings": [
|
||||
{
|
||||
"command": "cline.addToChat",
|
||||
"key": "cmd+'",
|
||||
"mac": "cmd+'",
|
||||
"win": "ctrl+'",
|
||||
"linux": "ctrl+'",
|
||||
"when": "editorHasSelection"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
"view/title": [
|
||||
{
|
||||
@@ -146,19 +151,46 @@
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.openDocumentation",
|
||||
"command": "cline.accountButtonClicked",
|
||||
"group": "navigation@5",
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.accountButtonClicked",
|
||||
"command": "cline.settingsButtonClicked",
|
||||
"group": "navigation@6",
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
}
|
||||
],
|
||||
"editor/title": [
|
||||
{
|
||||
"command": "cline.plusButtonClicked",
|
||||
"group": "navigation@1",
|
||||
"when": "activeWebviewPanelId == claude-dev.TabPanelProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.mcpButtonClicked",
|
||||
"group": "navigation@2",
|
||||
"when": "activeWebviewPanelId == claude-dev.TabPanelProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.historyButtonClicked",
|
||||
"group": "navigation@3",
|
||||
"when": "activeWebviewPanelId == claude-dev.TabPanelProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.popoutButtonClicked",
|
||||
"group": "navigation@4",
|
||||
"when": "activeWebviewPanelId == claude-dev.TabPanelProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.accountButtonClicked",
|
||||
"group": "navigation@5",
|
||||
"when": "activeWebviewPanelId == claude-dev.TabPanelProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.settingsButtonClicked",
|
||||
"group": "navigation@7",
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
"group": "navigation@6",
|
||||
"when": "activeWebviewPanelId == claude-dev.TabPanelProvider"
|
||||
}
|
||||
],
|
||||
"editor/context": [
|
||||
@@ -270,8 +302,12 @@
|
||||
"compile": "npm run check-types && npm run lint && node esbuild.js",
|
||||
"watch": "npm-run-all -p watch:*",
|
||||
"watch:esbuild": "node esbuild.js --watch",
|
||||
"watch:esbuild:test": "IS_TEST=true node esbuild.js --watch",
|
||||
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
|
||||
"package": "npm run build:webview && npm run check-types && npm run lint && node esbuild.js --production",
|
||||
"package:test": "IS_TEST=true npm run build:webview:test && npm run check-types && npm run lint && IS_TEST=true node esbuild.js --production",
|
||||
"build:webview:test": "cd webview-ui && IS_TEST=true npm run build",
|
||||
"watch:test": "IS_TEST=true npm-run-all -p watch:tsc watch:esbuild:test",
|
||||
"compile-tests": "tsc -p ./tsconfig.test.json --outDir out",
|
||||
"watch-tests": "tsc -p . -w --outDir out",
|
||||
"pretest": "npm run compile-tests && npm run compile && npm run lint",
|
||||
@@ -279,8 +315,11 @@
|
||||
"lint": "eslint src --ext ts && eslint webview-ui/src --ext ts",
|
||||
"format": "prettier . --check",
|
||||
"format:fix": "prettier . --write",
|
||||
"test": "vscode-test",
|
||||
"test": "npm-run-all test:unit test:integration",
|
||||
"test:ci": "node scripts/test-ci.js",
|
||||
"test:integration": "vscode-test",
|
||||
"test:unit": "TS_NODE_PROJECT='./tsconfig.unit-test.json' mocha",
|
||||
"test:coverage": "vscode-test --coverage",
|
||||
"install:all": "npm install && cd webview-ui && npm install",
|
||||
"dev:webview": "cd webview-ui && npm run dev",
|
||||
"build:webview": "cd webview-ui && npm run build",
|
||||
@@ -294,11 +333,16 @@
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.27.12",
|
||||
"@types/chai": "^5.0.1",
|
||||
"@types/clone-deep": "^4.0.4",
|
||||
"@types/diff": "^5.2.1",
|
||||
"@types/get-folder-size": "^3.0.4",
|
||||
"@types/mocha": "^10.0.7",
|
||||
"@types/node": "20.x",
|
||||
"@types/pdf-parse": "^1.1.4",
|
||||
"@types/proxyquire": "^1.3.31",
|
||||
"@types/should": "^11.2.0",
|
||||
"@types/sinon": "^17.0.4",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"@types/vscode": "^1.84.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.14.1",
|
||||
"@typescript-eslint/parser": "^7.11.0",
|
||||
@@ -310,8 +354,10 @@
|
||||
"husky": "^9.1.7",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^3.3.3",
|
||||
"proxyquire": "^2.1.3",
|
||||
"should": "^13.2.3",
|
||||
"sinon": "^19.0.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -329,20 +375,19 @@
|
||||
"@opentelemetry/sdk-node": "^0.39.1",
|
||||
"@opentelemetry/sdk-trace-node": "^1.30.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.30.0",
|
||||
"@types/clone-deep": "^4.0.4",
|
||||
"@types/get-folder-size": "^3.0.4",
|
||||
"@types/pdf-parse": "^1.1.4",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"@sentry/browser": "^9.12.0",
|
||||
"@vscode/codicons": "^0.0.36",
|
||||
"axios": "^1.8.2",
|
||||
"cheerio": "^1.0.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"chrome-launcher": "^1.1.2",
|
||||
"clone-deep": "^4.0.1",
|
||||
"default-shell": "^2.2.0",
|
||||
"diff": "^5.2.0",
|
||||
"execa": "^9.5.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"firebase": "^11.2.0",
|
||||
"fzf": "^0.5.2",
|
||||
"get-folder-size": "^5.0.0",
|
||||
"globby": "^14.0.2",
|
||||
"ignore": "^7.0.3",
|
||||
|
||||
+2
-2
@@ -9,10 +9,10 @@ try {
|
||||
execSync("which xvfb-run", { stdio: "ignore" })
|
||||
|
||||
console.log("xvfb-run is installed. Running tests with xvfb-run...")
|
||||
execSync("xvfb-run -a npm run test", { stdio: "inherit" })
|
||||
execSync("xvfb-run -a npm run test:integration", { stdio: "inherit" })
|
||||
} else {
|
||||
console.log("Non-Linux environment detected. Running tests normally.")
|
||||
execSync("npm run test", { stdio: "inherit" })
|
||||
execSync("npm run test:integration", { stdio: "inherit" })
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.platform === "linux") {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { RequestyHandler } from "./providers/requesty"
|
||||
import { TogetherHandler } from "./providers/together"
|
||||
import { QwenHandler } from "./providers/qwen"
|
||||
import { MistralHandler } from "./providers/mistral"
|
||||
import { DoubaoHandler } from "./providers/doubao"
|
||||
import { VsCodeLmHandler } from "./providers/vscode-lm"
|
||||
import { ClineHandler } from "./providers/cline"
|
||||
import { LiteLlmHandler } from "./providers/litellm"
|
||||
@@ -61,6 +62,8 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
|
||||
return new TogetherHandler(options)
|
||||
case "qwen":
|
||||
return new QwenHandler(options)
|
||||
case "doubao":
|
||||
return new DoubaoHandler(options)
|
||||
case "mistral":
|
||||
return new MistralHandler(options)
|
||||
case "vscode-lm":
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { describe, it, beforeEach, afterEach, before } from "mocha"
|
||||
import "should"
|
||||
import sinon from "sinon"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { OllamaHandler } from "../ollama"
|
||||
import { ApiHandlerOptions } from "../../../shared/api"
|
||||
import axios from "axios"
|
||||
|
||||
describe("OllamaHandler", () => {
|
||||
let ollamaAvailable = false
|
||||
|
||||
// Check if Ollama is running before running tests
|
||||
before(async function () {
|
||||
this.timeout(5000)
|
||||
try {
|
||||
await axios.get("http://localhost:11434/api/version", { timeout: 2000 })
|
||||
ollamaAvailable = true
|
||||
} catch (error) {
|
||||
console.log("Ollama server not available, skipping tests")
|
||||
ollamaAvailable = false
|
||||
}
|
||||
})
|
||||
let handler: OllamaHandler
|
||||
let options: ApiHandlerOptions
|
||||
let clock: sinon.SinonFakeTimers
|
||||
|
||||
beforeEach(() => {
|
||||
options = {
|
||||
ollamaModelId: "llama2",
|
||||
ollamaBaseUrl: "http://localhost:11434",
|
||||
}
|
||||
handler = new OllamaHandler(options)
|
||||
// Use fake timers for testing timeouts
|
||||
clock = sinon.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clock.restore()
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe("createMessage", () => {
|
||||
it("should handle successful responses", async function () {
|
||||
if (!ollamaAvailable) {
|
||||
this.skip()
|
||||
}
|
||||
this.timeout(5000)
|
||||
// Mock the Ollama client's chat method
|
||||
const chatStub = sinon.stub(handler["client"], "chat").resolves({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
message: { content: "Hello, world!" },
|
||||
eval_count: 10,
|
||||
prompt_eval_count: 20,
|
||||
}
|
||||
},
|
||||
} as any)
|
||||
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
const result = []
|
||||
const usageInfo = []
|
||||
|
||||
// Collect the results
|
||||
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
|
||||
if (chunk.type === "text") {
|
||||
result.push(chunk.text)
|
||||
} else if (chunk.type === "usage") {
|
||||
usageInfo.push({
|
||||
inputTokens: chunk.inputTokens,
|
||||
outputTokens: chunk.outputTokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the results
|
||||
result.should.deepEqual(["Hello, world!"])
|
||||
usageInfo.should.deepEqual([{ inputTokens: 20, outputTokens: 10 }])
|
||||
chatStub.calledOnce.should.be.true()
|
||||
})
|
||||
|
||||
it("should handle timeout errors", async function () {
|
||||
if (!ollamaAvailable) {
|
||||
this.skip()
|
||||
}
|
||||
this.timeout(10000)
|
||||
// Restore real timers for this test
|
||||
clock.restore()
|
||||
|
||||
// Create a handler with a very short timeout for testing
|
||||
const testHandler = new OllamaHandler(options)
|
||||
|
||||
// Replace the createMessage method with one that has a shorter timeout
|
||||
testHandler.createMessage = async function* (systemPrompt, messages) {
|
||||
try {
|
||||
// Create a promise that rejects after a short timeout
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Ollama request timed out after 30 seconds")), 100)
|
||||
})
|
||||
|
||||
// Create a promise that never resolves
|
||||
const neverPromise = new Promise(() => {})
|
||||
|
||||
// Race them
|
||||
await Promise.race([timeoutPromise, neverPromise])
|
||||
} catch (error: any) {
|
||||
// Enhance error reporting
|
||||
console.error(`Ollama API error: ${error.message}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
// Start the request and catch the error
|
||||
let errorMessage = ""
|
||||
try {
|
||||
for await (const _ of testHandler.createMessage(systemPrompt, messages)) {
|
||||
// This should not be reached
|
||||
}
|
||||
} catch (error: any) {
|
||||
errorMessage = error.message
|
||||
}
|
||||
|
||||
// Check the result
|
||||
errorMessage.should.equal("Ollama request timed out after 30 seconds")
|
||||
|
||||
// Restore the fake timers for other tests
|
||||
clock = sinon.useFakeTimers()
|
||||
})
|
||||
|
||||
it("should retry on errors when using the withRetry decorator", async function () {
|
||||
if (!ollamaAvailable) {
|
||||
this.skip()
|
||||
}
|
||||
this.timeout(10000)
|
||||
// Restore real timers for this test
|
||||
clock.restore()
|
||||
|
||||
// Mock the Ollama client's chat method to fail on first call and succeed on second
|
||||
const chatStub = sinon.stub(handler["client"], "chat")
|
||||
|
||||
// First call throws an error
|
||||
chatStub.onFirstCall().rejects(new Error("API Error"))
|
||||
|
||||
// Second call succeeds
|
||||
chatStub.onSecondCall().resolves({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
message: { content: "Success after retry" },
|
||||
}
|
||||
},
|
||||
} as any)
|
||||
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
const result = []
|
||||
|
||||
// Add a small delay to ensure the retry mechanism has time to work
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// Collect the results
|
||||
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
|
||||
if (chunk.type === "text") {
|
||||
result.push(chunk.text)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the results
|
||||
result.should.deepEqual(["Success after retry"])
|
||||
chatStub.calledTwice.should.be.true()
|
||||
|
||||
// Restore the fake timers for other tests
|
||||
clock = sinon.useFakeTimers()
|
||||
})
|
||||
|
||||
it("should handle stream processing errors", async function () {
|
||||
if (!ollamaAvailable) {
|
||||
this.skip()
|
||||
}
|
||||
this.timeout(10000)
|
||||
// Restore real timers for this test
|
||||
clock.restore()
|
||||
|
||||
// Create a handler with a custom implementation for testing
|
||||
const testHandler = new OllamaHandler(options)
|
||||
|
||||
// Replace the createMessage method with one that simulates a stream error
|
||||
testHandler.createMessage = async function* (systemPrompt, messages) {
|
||||
// First yield a successful chunk
|
||||
yield {
|
||||
type: "text",
|
||||
text: "Partial response",
|
||||
}
|
||||
|
||||
// Then throw an error in the stream
|
||||
throw new Error("Ollama stream processing error: Stream error")
|
||||
}
|
||||
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
const result = []
|
||||
|
||||
// Collect the results and catch the error
|
||||
let errorMessage = ""
|
||||
try {
|
||||
for await (const chunk of testHandler.createMessage(systemPrompt, messages)) {
|
||||
if (chunk.type === "text") {
|
||||
result.push(chunk.text)
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
errorMessage = error.message
|
||||
}
|
||||
|
||||
// Verify the results
|
||||
errorMessage.should.equal("Ollama stream processing error: Stream error")
|
||||
result.should.deepEqual(["Partial response"])
|
||||
|
||||
// Restore the fake timers for other tests
|
||||
clock = sinon.useFakeTimers()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -23,7 +23,7 @@ export class AnthropicHandler implements ApiHandler {
|
||||
let stream: AnthropicStream<Anthropic.RawMessageStreamEvent>
|
||||
const modelId = model.id
|
||||
|
||||
let budget_tokens = this.options.thinkingBudgetTokens || 0
|
||||
const budget_tokens = this.options.thinkingBudgetTokens || 0
|
||||
const reasoningOn = modelId.includes("3-7") && budget_tokens !== 0 ? true : false
|
||||
|
||||
switch (modelId) {
|
||||
@@ -114,7 +114,7 @@ export class AnthropicHandler implements ApiHandler {
|
||||
break
|
||||
}
|
||||
default: {
|
||||
stream = (await this.client.messages.create({
|
||||
stream = await this.client.messages.create({
|
||||
model: modelId,
|
||||
max_tokens: model.info.maxTokens || 8192,
|
||||
temperature: 0,
|
||||
@@ -123,7 +123,7 @@ export class AnthropicHandler implements ApiHandler {
|
||||
// tools,
|
||||
// tool_choice: { type: "auto" },
|
||||
stream: true,
|
||||
})) as any
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
// cross region inference requires prefixing the model id with the region
|
||||
let modelId = await this.getModelId()
|
||||
const modelId = await this.getModelId()
|
||||
const model = this.getModel()
|
||||
|
||||
// Check if this is an Amazon Nova model
|
||||
@@ -40,7 +40,7 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
return
|
||||
}
|
||||
|
||||
let budget_tokens = this.options.thinkingBudgetTokens || 0
|
||||
const budget_tokens = this.options.thinkingBudgetTokens || 0
|
||||
const reasoningOn = modelId.includes("3-7") && budget_tokens !== 0 ? true : false
|
||||
|
||||
// Get model info and message indices for caching
|
||||
@@ -250,7 +250,7 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
*/
|
||||
async getModelId(): Promise<string> {
|
||||
if (this.options.awsUseCrossRegionInference) {
|
||||
let regionPrefix = this.getRegion().slice(0, 3)
|
||||
const regionPrefix = this.getRegion().slice(0, 3)
|
||||
switch (regionPrefix) {
|
||||
case "us-":
|
||||
return `us.${this.getModel().id}`
|
||||
|
||||
@@ -17,6 +17,11 @@ export class ClineHandler implements ApiHandler {
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://api.cline.bot/v1",
|
||||
apiKey: this.options.clineApiKey || "",
|
||||
defaultHeaders: {
|
||||
"HTTP-Referer": "https://cline.bot", // Optional, for including your app on cline.bot rankings.
|
||||
"X-Title": "Cline", // Optional. Shows in rankings on cline.bot.
|
||||
"X-Task-ID": this.options.taskId || "", // Include the task ID in the request headers
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -36,14 +36,15 @@ export class DeepSeekHandler implements ApiHandler {
|
||||
}
|
||||
const deepUsage = usage as DeepSeekUsage
|
||||
|
||||
const inputTokens = deepUsage?.prompt_tokens || 0
|
||||
const inputTokens = deepUsage?.prompt_tokens || 0 // sum of cache hits and misses
|
||||
const outputTokens = deepUsage?.completion_tokens || 0
|
||||
const cacheReadTokens = deepUsage?.prompt_cache_hit_tokens || 0
|
||||
const cacheWriteTokens = deepUsage?.prompt_cache_miss_tokens || 0
|
||||
const totalCost = calculateApiCostOpenAI(info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
|
||||
const nonCachedInputTokens = Math.max(0, inputTokens - cacheReadTokens - cacheWriteTokens) // this will always be 0
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: inputTokens,
|
||||
inputTokens: nonCachedInputTokens,
|
||||
outputTokens: outputTokens,
|
||||
cacheWriteTokens: cacheWriteTokens,
|
||||
cacheReadTokens: cacheReadTokens,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ApiHandler } from ".."
|
||||
import { ApiHandlerOptions, doubaoDefaultModelId, DoubaoModelId, doubaoModels, ModelInfo } from "../../shared/api"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
|
||||
export class DoubaoHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://ark.cn-beijing.volces.com/api/v3/",
|
||||
apiKey: this.options.doubaoApiKey,
|
||||
})
|
||||
}
|
||||
|
||||
getModel(): { id: DoubaoModelId; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in doubaoModels) {
|
||||
const id = modelId as DoubaoModelId
|
||||
return { id, info: doubaoModels[id] }
|
||||
}
|
||||
return {
|
||||
id: doubaoDefaultModelId,
|
||||
info: doubaoModels[doubaoDefaultModelId],
|
||||
}
|
||||
}
|
||||
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const model = this.getModel()
|
||||
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: model.id,
|
||||
max_completion_tokens: model.info.maxTokens,
|
||||
messages: openAiMessages,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
temperature: 0,
|
||||
})
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
cacheReadTokens: chunk.usage.prompt_cache_hit_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
cacheWriteTokens: chunk.usage.prompt_cache_miss_tokens || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,40 @@ export class LiteLlmHandler implements ApiHandler {
|
||||
})
|
||||
}
|
||||
|
||||
async calculateCost(prompt_tokens: number, completion_tokens: number): Promise<number | undefined> {
|
||||
// Reference: https://github.com/BerriAI/litellm/blob/122ee634f434014267af104814022af1d9a0882f/litellm/proxy/spend_tracking/spend_management_endpoints.py#L1473
|
||||
const modelId = this.options.liteLlmModelId || liteLlmDefaultModelId
|
||||
try {
|
||||
const response = await fetch(`${this.client.baseURL}/spend/calculate`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.options.liteLlmApiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
completion_response: {
|
||||
model: modelId,
|
||||
usage: {
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data: { cost: number } = await response.json()
|
||||
return data.cost
|
||||
} else {
|
||||
console.error("Error calculating spend:", response.statusText)
|
||||
return undefined
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error calculating spend:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const formattedMessages = convertToOpenAiMessages(messages)
|
||||
const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
|
||||
@@ -25,22 +59,62 @@ export class LiteLlmHandler implements ApiHandler {
|
||||
}
|
||||
const modelId = this.options.liteLlmModelId || liteLlmDefaultModelId
|
||||
const isOminiModel = modelId.includes("o1-mini") || modelId.includes("o3-mini")
|
||||
|
||||
// Configuration for extended thinking
|
||||
const budgetTokens = this.options.thinkingBudgetTokens || 0
|
||||
const reasoningOn = budgetTokens !== 0 ? true : false
|
||||
const thinkingConfig = reasoningOn ? { type: "enabled", budget_tokens: budgetTokens } : undefined
|
||||
|
||||
let temperature: number | undefined = 0
|
||||
|
||||
if (isOminiModel) {
|
||||
temperature = undefined // does not support temperature
|
||||
if (isOminiModel && reasoningOn) {
|
||||
temperature = undefined // Thinking mode doesn't support temperature
|
||||
}
|
||||
|
||||
// Define cache control object if prompt caching is enabled
|
||||
const cacheControl = this.options.liteLlmUsePromptCache ? { cache_control: { type: "ephemeral" } } : undefined
|
||||
|
||||
// Add cache_control to system message if enabled
|
||||
const enhancedSystemMessage = {
|
||||
...systemMessage,
|
||||
...(cacheControl && cacheControl),
|
||||
}
|
||||
|
||||
// Find the last two user messages to apply caching
|
||||
const userMsgIndices = formattedMessages.reduce(
|
||||
(acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc),
|
||||
[] as number[],
|
||||
)
|
||||
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
|
||||
const secondLastUserMsgIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
|
||||
|
||||
// Apply cache_control to the last two user messages if enabled
|
||||
const enhancedMessages = formattedMessages.map((message, index) => {
|
||||
if ((index === lastUserMsgIndex || index === secondLastUserMsgIndex) && cacheControl) {
|
||||
return {
|
||||
...message,
|
||||
...cacheControl,
|
||||
}
|
||||
}
|
||||
return message
|
||||
})
|
||||
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: this.options.liteLlmModelId || liteLlmDefaultModelId,
|
||||
messages: [systemMessage, ...formattedMessages],
|
||||
messages: [enhancedSystemMessage, ...enhancedMessages],
|
||||
temperature,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
...(thinkingConfig && { thinking: thinkingConfig }), // Add thinking configuration when applicable
|
||||
})
|
||||
|
||||
const inputCost = (await this.calculateCost(1e6, 0)) || 0
|
||||
const outputCost = (await this.calculateCost(0, 1e6)) || 0
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta
|
||||
|
||||
// Handle normal text content
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
@@ -48,11 +122,45 @@ export class LiteLlmHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle reasoning events (thinking)
|
||||
// Thinking is not in the standard types but may be in the response
|
||||
interface ThinkingDelta {
|
||||
thinking?: string
|
||||
}
|
||||
|
||||
if ((delta as ThinkingDelta)?.thinking) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: (delta as ThinkingDelta).thinking || "",
|
||||
}
|
||||
}
|
||||
|
||||
// Handle token usage information
|
||||
if (chunk.usage) {
|
||||
const totalCost =
|
||||
(inputCost * chunk.usage.prompt_tokens) / 1e6 + (outputCost * chunk.usage.completion_tokens) / 1e6
|
||||
|
||||
// Extract cache-related information if available
|
||||
// Need to use type assertion since these properties are not in the standard OpenAI types
|
||||
const usage = chunk.usage as {
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
cache_creation_input_tokens?: number
|
||||
prompt_cache_miss_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
prompt_cache_hit_tokens?: number
|
||||
}
|
||||
|
||||
const cacheWriteTokens = usage.cache_creation_input_tokens || usage.prompt_cache_miss_tokens || 0
|
||||
const cacheReadTokens = usage.cache_read_input_tokens || usage.prompt_cache_hit_tokens || 0
|
||||
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
inputTokens: usage.prompt_tokens || 0,
|
||||
outputTokens: usage.completion_tokens || 0,
|
||||
cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined,
|
||||
cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined,
|
||||
totalCost,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+53
-13
@@ -4,6 +4,7 @@ import { ApiHandler } from "../"
|
||||
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api"
|
||||
import { convertToOllamaMessages } from "../transform/ollama-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { withRetry } from "../retry"
|
||||
|
||||
export class OllamaHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
@@ -14,24 +15,63 @@ export class OllamaHandler implements ApiHandler {
|
||||
this.client = new Ollama({ host: this.options.ollamaBaseUrl || "http://localhost:11434" })
|
||||
}
|
||||
|
||||
@withRetry({ retryAllErrors: true })
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const ollamaMessages: Message[] = [{ role: "system", content: systemPrompt }, ...convertToOllamaMessages(messages)]
|
||||
|
||||
const stream = await this.client.chat({
|
||||
model: this.getModel().id,
|
||||
messages: ollamaMessages,
|
||||
stream: true,
|
||||
options: {
|
||||
num_ctx: Number(this.options.ollamaApiOptionsCtxNum) || 32768,
|
||||
},
|
||||
})
|
||||
for await (const chunk of stream) {
|
||||
if (typeof chunk.message.content === "string") {
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.message.content,
|
||||
try {
|
||||
// Create a promise that rejects after timeout
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Ollama request timed out after 30 seconds")), 30000)
|
||||
})
|
||||
|
||||
// Create the actual API request promise
|
||||
const apiPromise = this.client.chat({
|
||||
model: this.getModel().id,
|
||||
messages: ollamaMessages,
|
||||
stream: true,
|
||||
options: {
|
||||
num_ctx: Number(this.options.ollamaApiOptionsCtxNum) || 32768,
|
||||
},
|
||||
})
|
||||
|
||||
// Race the API request against the timeout
|
||||
const stream = (await Promise.race([apiPromise, timeoutPromise])) as Awaited<typeof apiPromise>
|
||||
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
if (typeof chunk.message.content === "string") {
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.message.content,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle token usage if available
|
||||
if (chunk.eval_count !== undefined || chunk.prompt_eval_count !== undefined) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.prompt_eval_count || 0,
|
||||
outputTokens: chunk.eval_count || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (streamError: any) {
|
||||
console.error("Error processing Ollama stream:", streamError)
|
||||
throw new Error(`Ollama stream processing error: ${streamError.message || "Unknown error"}`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Check if it's a timeout error
|
||||
if (error.message && error.message.includes("timed out")) {
|
||||
throw new Error("Ollama request timed out after 30 seconds")
|
||||
}
|
||||
|
||||
// Enhance error reporting
|
||||
const statusCode = error.status || error.statusCode
|
||||
const errorMessage = error.message || "Unknown error"
|
||||
|
||||
console.error(`Ollama API error (${statusCode || "unknown"}): ${errorMessage}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,14 +26,15 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
|
||||
const inputTokens = usage?.prompt_tokens || 0
|
||||
const inputTokens = usage?.prompt_tokens || 0 // sum of cache hits and misses
|
||||
const outputTokens = usage?.completion_tokens || 0
|
||||
const cacheReadTokens = usage?.prompt_tokens_details?.cached_tokens || 0
|
||||
const cacheWriteTokens = 0
|
||||
const totalCost = calculateApiCostOpenAI(info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
|
||||
const nonCachedInputTokens = Math.max(0, inputTokens - cacheReadTokens - cacheWriteTokens)
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: inputTokens,
|
||||
inputTokens: nonCachedInputTokens,
|
||||
outputTokens: outputTokens,
|
||||
cacheWriteTokens: cacheWriteTokens,
|
||||
cacheReadTokens: cacheReadTokens,
|
||||
|
||||
@@ -34,6 +34,7 @@ export class OpenAiHandler implements ApiHandler {
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const modelId = this.options.openAiModelId ?? ""
|
||||
const isDeepseekReasoner = modelId.includes("deepseek-reasoner")
|
||||
const isR1FormatRequired = this.options.openAiModelInfo?.isR1FormatRequired ?? false
|
||||
const isO3Mini = modelId.includes("o3-mini")
|
||||
|
||||
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
@@ -42,8 +43,15 @@ export class OpenAiHandler implements ApiHandler {
|
||||
]
|
||||
let temperature: number | undefined = this.options.openAiModelInfo?.temperature ?? openAiModelInfoSaneDefaults.temperature
|
||||
let reasoningEffort: ChatCompletionReasoningEffort | undefined = undefined
|
||||
let maxTokens: number | undefined
|
||||
|
||||
if (isDeepseekReasoner) {
|
||||
if (this.options.openAiModelInfo?.maxTokens && this.options.openAiModelInfo.maxTokens > 0) {
|
||||
maxTokens = Number(this.options.openAiModelInfo.maxTokens)
|
||||
} else {
|
||||
maxTokens = undefined
|
||||
}
|
||||
|
||||
if (isDeepseekReasoner || isR1FormatRequired) {
|
||||
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
}
|
||||
|
||||
@@ -57,6 +65,7 @@ export class OpenAiHandler implements ApiHandler {
|
||||
model: modelId,
|
||||
messages: openAiMessages,
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
reasoning_effort: reasoningEffort,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api"
|
||||
import { ApiHandlerOptions, ModelInfo, requestyDefaultModelId, requestyDefaultModelInfo } from "../../shared/api"
|
||||
import { ApiHandler } from "../index"
|
||||
import { withRetry } from "../retry"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { calculateApiCostOpenAI } from "../../utils/cost"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
|
||||
export class RequestyHandler implements ApiHandler {
|
||||
@@ -24,21 +25,34 @@ export class RequestyHandler implements ApiHandler {
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const modelId = this.options.requestyModelId ?? ""
|
||||
const model = this.getModel()
|
||||
|
||||
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
|
||||
const reasoningEffort = this.options.o3MiniReasoningEffort || "medium"
|
||||
const reasoning = { reasoning_effort: reasoningEffort }
|
||||
const reasoningArgs = model.id === "openai/o3-mini" ? reasoning : {}
|
||||
|
||||
const thinkingBudget = this.options.thinkingBudgetTokens || 0
|
||||
const thinking =
|
||||
thinkingBudget > 0
|
||||
? { thinking: { type: "enabled", budget_tokens: thinkingBudget } }
|
||||
: { thinking: { type: "disabled" } }
|
||||
const thinkingArgs = model.id.includes("claude-3-7-sonnet") ? thinking : {}
|
||||
|
||||
// @ts-ignore-next-line
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: modelId,
|
||||
model: model.id,
|
||||
max_tokens: model.info.maxTokens || undefined,
|
||||
messages: openAiMessages,
|
||||
temperature: 0,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
...(modelId === "openai/o3-mini" ? { reasoning_effort: this.options.o3MiniReasoningEffort || "medium" } : {}),
|
||||
...reasoningArgs,
|
||||
...thinkingArgs,
|
||||
})
|
||||
|
||||
for await (const chunk of stream) {
|
||||
@@ -73,7 +87,7 @@ export class RequestyHandler implements ApiHandler {
|
||||
const outputTokens = usage.completion_tokens || 0
|
||||
const cacheWriteTokens = usage.prompt_tokens_details?.caching_tokens || undefined
|
||||
const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens || undefined
|
||||
const totalCost = 0 // TODO: Replace with calculateApiCostOpenAI(model.info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
|
||||
const totalCost = calculateApiCostOpenAI(model.info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
|
||||
|
||||
yield {
|
||||
type: "usage",
|
||||
@@ -88,9 +102,11 @@ export class RequestyHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
return {
|
||||
id: this.options.requestyModelId ?? "",
|
||||
info: openAiModelInfoSaneDefaults,
|
||||
const modelId = this.options.requestyModelId
|
||||
const modelInfo = this.options.requestyModelInfo
|
||||
if (modelId && modelInfo) {
|
||||
return { id: modelId, info: modelInfo }
|
||||
}
|
||||
return { id: requestyDefaultModelId, info: requestyDefaultModelInfo }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ApiHandler } from "../"
|
||||
import { ApiHandlerOptions, XAIModelId, ModelInfo, xaiDefaultModelId, xaiModels } from "../../shared/api"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { ChatCompletionReasoningEffort } from "openai/resources/chat/completions.mjs"
|
||||
|
||||
export class XAIHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
@@ -18,13 +19,22 @@ export class XAIHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const modelId = this.getModel().id
|
||||
// ensure reasoning effort is either "low" or "high" for grok-3-mini
|
||||
let reasoningEffort: ChatCompletionReasoningEffort | undefined
|
||||
if (modelId.includes("3-mini")) {
|
||||
let reasoningEffort = this.options.reasoningEffort
|
||||
if (reasoningEffort && !["low", "high"].includes(reasoningEffort)) {
|
||||
reasoningEffort = undefined
|
||||
}
|
||||
}
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: this.getModel().id,
|
||||
model: modelId,
|
||||
max_completion_tokens: this.getModel().info.maxTokens,
|
||||
temperature: 0,
|
||||
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
reasoning_effort: reasoningEffort,
|
||||
})
|
||||
|
||||
for await (const chunk of stream) {
|
||||
@@ -36,6 +46,14 @@ export class XAIHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
if ("reasoning_content" in delta && delta.reasoning_content) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
// @ts-ignore-next-line
|
||||
reasoning: delta.reasoning_content,
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
|
||||
@@ -20,7 +20,7 @@ export function convertAnthropicContentToGemini(content: string | Anthropic.Cont
|
||||
},
|
||||
} as InlineDataPart
|
||||
default:
|
||||
throw new Error(`Unsupported content block type: ${(block as any).type}`)
|
||||
throw new Error(`Unsupported content block type: ${block.type}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -307,7 +307,7 @@ function parseToolCall(toolName: string, content: string): ToolCall | null {
|
||||
|
||||
// Parse nested XML elements
|
||||
const paramRegex = /<(\w+)>([\s\S]*?)<\/\1>/gs
|
||||
let match
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = paramRegex.exec(innerContent)) !== null) {
|
||||
const [, paramName, paramValue] = match
|
||||
|
||||
@@ -28,7 +28,7 @@ export function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.Me
|
||||
)
|
||||
|
||||
// Process tool result messages FIRST since they must follow the tool use messages
|
||||
let toolResultImages: string[] = []
|
||||
const toolResultImages: string[] = []
|
||||
toolMessages.forEach((toolMessage) => {
|
||||
// The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the Ollama SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility.
|
||||
let content: string
|
||||
|
||||
@@ -38,7 +38,7 @@ export function convertToOpenAiMessages(
|
||||
)
|
||||
|
||||
// Process tool result messages FIRST since they must follow the tool use messages
|
||||
let toolResultImages: Anthropic.Messages.ImageBlockParam[] = []
|
||||
const toolResultImages: Anthropic.Messages.ImageBlockParam[] = []
|
||||
toolMessages.forEach((toolMessage) => {
|
||||
// The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the OpenAI SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility.
|
||||
let content: string
|
||||
@@ -127,7 +127,7 @@ export function convertToOpenAiMessages(
|
||||
}
|
||||
|
||||
// Process tool use messages
|
||||
let tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolMessages.map((toolMessage) => ({
|
||||
const tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolMessages.map((toolMessage) => ({
|
||||
id: toolMessage.id,
|
||||
type: "function",
|
||||
function: {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Core Architecture
|
||||
|
||||
Extension entry point (extension.ts) -> webview -> controller -> task
|
||||
|
||||
```tree
|
||||
core/
|
||||
├── webview/ # Manages webview lifecycle
|
||||
├── controller/ # Handles webview messages and task management
|
||||
├── task/ # Executes API requests and tool operations
|
||||
└── ... # Additional components to help with context, parsing user/assistant messages, etc.
|
||||
```
|
||||
@@ -284,7 +284,7 @@ export async function constructNewFileContent(diffContent: string, originalConte
|
||||
;[searchMatchIndex, searchEndIndex] = blockMatch
|
||||
} else {
|
||||
throw new Error(
|
||||
`The SEARCH block:\n${currentSearchContent.trimEnd()}\n...does not match anything in the file.`,
|
||||
`The SEARCH block:\n${currentSearchContent.trimEnd()}\n...does not match anything in the file or was searched out of order in the provided blocks.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,9 @@ export const toolUseNames = [
|
||||
"use_mcp_tool",
|
||||
"access_mcp_resource",
|
||||
"ask_followup_question",
|
||||
"plan_mode_response",
|
||||
"plan_mode_respond",
|
||||
"attempt_completion",
|
||||
"new_task",
|
||||
] as const
|
||||
|
||||
// Converts array of tool call names into a union type ("execute_command" | "read_file" | ...)
|
||||
@@ -48,6 +49,7 @@ export const toolParamNames = [
|
||||
"options",
|
||||
"response",
|
||||
"result",
|
||||
"context",
|
||||
] as const
|
||||
|
||||
export type ToolParamName = (typeof toolParamNames)[number]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AssistantMessageContent, TextContent, ToolUse, ToolParamName, toolParamNames, toolUseNames, ToolUseName } from "."
|
||||
|
||||
export function parseAssistantMessage(assistantMessage: string) {
|
||||
let contentBlocks: AssistantMessageContent[] = []
|
||||
const contentBlocks: AssistantMessageContent[] = []
|
||||
let currentTextContent: TextContent | undefined = undefined
|
||||
let currentTextContentStartIndex = 0
|
||||
let currentToolUse: ToolUse | undefined = undefined
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export function checkIsOpenRouterContextWindowError(error: any): boolean {
|
||||
return error.code === 400 && error.message?.includes("context length")
|
||||
}
|
||||
|
||||
export function checkIsAnthropicContextWindowError(response: any): boolean {
|
||||
return (
|
||||
response?.error?.error?.type === "invalid_request_error" &&
|
||||
response?.error?.error?.message?.includes("prompt is too long")
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,834 @@
|
||||
import { getContextWindowInfo } from "./context-window-utils"
|
||||
import { formatResponse } from "../../prompts/responses"
|
||||
import { GlobalFileNames } from "../../storage/disk"
|
||||
import { fileExistsAtPath } from "../../../utils/fs"
|
||||
import * as path from "path"
|
||||
import fs from "fs/promises"
|
||||
import cloneDeep from "clone-deep"
|
||||
import { ClineApiReqInfo, ClineMessage } from "../../../shared/ExtensionMessage"
|
||||
import { ApiHandler } from "../../../api"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
enum EditType {
|
||||
UNDEFINED = 0,
|
||||
NO_FILE_READ = 1,
|
||||
READ_FILE_TOOL = 2,
|
||||
ALTER_FILE_TOOL = 3,
|
||||
FILE_MENTION = 4,
|
||||
}
|
||||
|
||||
// array of string values allows us to cover all changes for message types currently supported
|
||||
type MessageContent = string[]
|
||||
type MessageMetadata = string[][]
|
||||
|
||||
// Type for a single context update
|
||||
type ContextUpdate = [number, string, MessageContent, MessageMetadata] // [timestamp, updateType, update, metadata]
|
||||
|
||||
// Type for the serialized format of our nested maps
|
||||
type SerializedContextHistory = Array<
|
||||
[
|
||||
number, // messageIndex
|
||||
[
|
||||
number, // EditType (message type)
|
||||
Array<
|
||||
[
|
||||
number, // blockIndex
|
||||
ContextUpdate[], // updates array (now with 4 elements including metadata)
|
||||
]
|
||||
>,
|
||||
],
|
||||
]
|
||||
>
|
||||
|
||||
export class ContextManager {
|
||||
// mapping from the apiMessages outer index to the inner message index to a list of actual changes, ordered by timestamp
|
||||
// timestamp is required in order to support full checkpointing, where the changes we apply need to be able to be undone when
|
||||
// moving to an earlier conversation history checkpoint - this ordering intuitively allows for binary search on truncation
|
||||
// there is also a number stored for each (EditType) which defines which message type it is, for custom handling
|
||||
|
||||
// format: { outerIndex => [EditType, { innerIndex => [[timestamp, updateType, update], ...] }] }
|
||||
// example: { 1 => { [0, 0 => [[<timestamp>, "text", "[NOTE] Some previous conversation history with the user has been removed ..."], ...] }] }
|
||||
// the above example would be how we update the first assistant message to indicate we truncated text
|
||||
private contextHistoryUpdates: Map<number, [number, Map<number, ContextUpdate[]>]>
|
||||
|
||||
constructor() {
|
||||
this.contextHistoryUpdates = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
* public function for loading contextHistoryUpdates from disk, if it exists
|
||||
*/
|
||||
async initializeContextHistory(taskDirectory: string) {
|
||||
this.contextHistoryUpdates = await this.getSavedContextHistory(taskDirectory)
|
||||
}
|
||||
|
||||
/**
|
||||
* get the stored context history updates from disk
|
||||
*/
|
||||
private async getSavedContextHistory(taskDirectory: string): Promise<Map<number, [number, Map<number, ContextUpdate[]>]>> {
|
||||
try {
|
||||
const filePath = path.join(taskDirectory, GlobalFileNames.contextHistory)
|
||||
if (await fileExistsAtPath(filePath)) {
|
||||
const data = await fs.readFile(filePath, "utf8")
|
||||
const serializedUpdates = JSON.parse(data) as SerializedContextHistory
|
||||
|
||||
// Update to properly reconstruct the tuple structure
|
||||
return new Map(
|
||||
serializedUpdates.map(([messageIndex, [numberValue, innerMapArray]]) => [
|
||||
messageIndex,
|
||||
[numberValue, new Map(innerMapArray)],
|
||||
]),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load context history:", error)
|
||||
}
|
||||
return new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
* save the context history updates to disk
|
||||
*/
|
||||
private async saveContextHistory(taskDirectory: string) {
|
||||
try {
|
||||
const serializedUpdates: SerializedContextHistory = Array.from(this.contextHistoryUpdates.entries()).map(
|
||||
([messageIndex, [numberValue, innerMap]]) => [messageIndex, [numberValue, Array.from(innerMap.entries())]],
|
||||
)
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(taskDirectory, GlobalFileNames.contextHistory),
|
||||
JSON.stringify(serializedUpdates),
|
||||
"utf8",
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Failed to save context history:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* primary entry point for getting up to date context & truncating when required
|
||||
*/
|
||||
async getNewContextMessagesAndMetadata(
|
||||
apiConversationHistory: Anthropic.Messages.MessageParam[],
|
||||
clineMessages: ClineMessage[],
|
||||
api: ApiHandler,
|
||||
conversationHistoryDeletedRange: [number, number] | undefined,
|
||||
previousApiReqIndex: number,
|
||||
taskDirectory: string,
|
||||
) {
|
||||
let updatedConversationHistoryDeletedRange = false
|
||||
|
||||
// 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 totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
const { maxAllowedSize } = getContextWindowInfo(api)
|
||||
|
||||
// This is the most reliable way to know when we're close to hitting the context window.
|
||||
if (totalTokens >= maxAllowedSize) {
|
||||
// Since the user may switch between models with different context windows, truncating half may not be enough (ie if switching from claude 200k to deepseek 64k, half truncation will only remove 100k tokens, but we need to remove much more)
|
||||
// So if totalTokens/2 is greater than maxAllowedSize, we truncate 3/4 instead of 1/2
|
||||
const keep = totalTokens / 2 > maxAllowedSize ? "quarter" : "half"
|
||||
|
||||
// we later check how many chars we trim to determine if we should still truncate history
|
||||
let [anyContextUpdates, uniqueFileReadIndices] = this.applyContextOptimizations(
|
||||
apiConversationHistory,
|
||||
conversationHistoryDeletedRange ? conversationHistoryDeletedRange[1] + 1 : 2,
|
||||
timestamp,
|
||||
)
|
||||
|
||||
let needToTruncate = true
|
||||
if (anyContextUpdates) {
|
||||
// determine whether we've saved enough chars to not truncate
|
||||
const charactersSavedPercentage = this.calculateContextOptimizationMetrics(
|
||||
apiConversationHistory,
|
||||
conversationHistoryDeletedRange,
|
||||
uniqueFileReadIndices,
|
||||
)
|
||||
if (charactersSavedPercentage >= 0.3) {
|
||||
needToTruncate = false
|
||||
}
|
||||
}
|
||||
|
||||
if (needToTruncate) {
|
||||
// go ahead with truncation
|
||||
anyContextUpdates = this.applyStandardContextTruncationNoticeChange(timestamp) || anyContextUpdates
|
||||
|
||||
// NOTE: it's okay that we overwriteConversationHistory in resume task since we're only ever removing the last user message and not anything in the middle which would affect this range
|
||||
conversationHistoryDeletedRange = this.getNextTruncationRange(
|
||||
apiConversationHistory,
|
||||
conversationHistoryDeletedRange,
|
||||
keep,
|
||||
)
|
||||
|
||||
updatedConversationHistoryDeletedRange = true
|
||||
}
|
||||
|
||||
// if we alter the context history, save the updated version to disk
|
||||
if (anyContextUpdates) {
|
||||
await this.saveContextHistory(taskDirectory)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const truncatedConversationHistory = this.getAndAlterTruncatedMessages(
|
||||
apiConversationHistory,
|
||||
conversationHistoryDeletedRange,
|
||||
)
|
||||
|
||||
return {
|
||||
conversationHistoryDeletedRange: conversationHistoryDeletedRange,
|
||||
updatedConversationHistoryDeletedRange: updatedConversationHistoryDeletedRange,
|
||||
truncatedConversationHistory: truncatedConversationHistory,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* get truncation range
|
||||
*/
|
||||
public getNextTruncationRange(
|
||||
apiMessages: Anthropic.Messages.MessageParam[],
|
||||
currentDeletedRange: [number, number] | undefined,
|
||||
keep: "half" | "quarter",
|
||||
): [number, number] {
|
||||
// We always keep the first user-assistant pairing, and truncate an even number of messages from there
|
||||
const rangeStartIndex = 2 // index 0 and 1 are kept
|
||||
const startOfRest = currentDeletedRange ? currentDeletedRange[1] + 1 : 2 // inclusive starting index
|
||||
|
||||
let messagesToRemove: number
|
||||
if (keep === "half") {
|
||||
// Remove half of remaining user-assistant pairs
|
||||
// We first calculate half of the messages then divide by 2 to get the number of pairs.
|
||||
// After flooring, we multiply by 2 to get the number of messages.
|
||||
// Note that this will also always be an even number.
|
||||
messagesToRemove = Math.floor((apiMessages.length - startOfRest) / 4) * 2 // Keep even number
|
||||
} else {
|
||||
// Remove 3/4 of remaining user-assistant pairs
|
||||
// We calculate 3/4ths of the messages then divide by 2 to get the number of pairs.
|
||||
// After flooring, we multiply by 2 to get the number of messages.
|
||||
// Note that this will also always be an even number.
|
||||
messagesToRemove = Math.floor(((apiMessages.length - startOfRest) * 3) / 4 / 2) * 2
|
||||
}
|
||||
|
||||
let rangeEndIndex = startOfRest + messagesToRemove - 1 // inclusive ending index
|
||||
|
||||
// Make sure that the last message being removed is a assistant message, so the next message after the initial user-assistant pair is an assistant message. This preserves the user-assistant-user-assistant structure.
|
||||
// NOTE: anthropic format messages are always user-assistant-user-assistant, while openai format messages can have multiple user messages in a row (we use anthropic format throughout cline)
|
||||
if (apiMessages[rangeEndIndex].role !== "assistant") {
|
||||
rangeEndIndex -= 1
|
||||
}
|
||||
|
||||
// this is an inclusive range that will be removed from the conversation history
|
||||
return [rangeStartIndex, rangeEndIndex]
|
||||
}
|
||||
|
||||
/**
|
||||
* external interface to support old calls
|
||||
*/
|
||||
public getTruncatedMessages(
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
deletedRange: [number, number] | undefined,
|
||||
): Anthropic.Messages.MessageParam[] {
|
||||
return this.getAndAlterTruncatedMessages(messages, deletedRange)
|
||||
}
|
||||
|
||||
/**
|
||||
* apply all required truncation methods to the messages in context
|
||||
*/
|
||||
private getAndAlterTruncatedMessages(
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
deletedRange: [number, number] | undefined,
|
||||
): Anthropic.Messages.MessageParam[] {
|
||||
if (messages.length <= 1) {
|
||||
return messages
|
||||
}
|
||||
|
||||
const updatedMessages = this.applyContextHistoryUpdates(messages, deletedRange ? deletedRange[1] + 1 : 2)
|
||||
|
||||
// OLD NOTE: if you try to console log these, don't forget that logging a reference to an array may not provide the same result as logging a slice() snapshot of that array at that exact moment. The following DOES in fact include the latest assistant message.
|
||||
return updatedMessages
|
||||
}
|
||||
|
||||
/**
|
||||
* applies deletedRange truncation and other alterations based on changes in this.contextHistoryUpdates
|
||||
*/
|
||||
private applyContextHistoryUpdates(
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
startFromIndex: number,
|
||||
): Anthropic.Messages.MessageParam[] {
|
||||
// runtime is linear in length of user messages, if expecting a limited number of alterations, could be more optimal to loop over alterations
|
||||
|
||||
const firstChunk = messages.slice(0, 2) // get first user-assistant pair
|
||||
const secondChunk = messages.slice(startFromIndex) // get remaining messages within context
|
||||
const messagesToUpdate = [...firstChunk, ...secondChunk]
|
||||
|
||||
// we need the mapping from the local indices in messagesToUpdate to the global array of updates in this.contextHistoryUpdates
|
||||
const originalIndices = [
|
||||
...Array(2).keys(),
|
||||
...Array(secondChunk.length)
|
||||
.fill(0)
|
||||
.map((_, i) => i + startFromIndex),
|
||||
]
|
||||
|
||||
for (let arrayIndex = 0; arrayIndex < messagesToUpdate.length; arrayIndex++) {
|
||||
const messageIndex = originalIndices[arrayIndex]
|
||||
|
||||
const innerTuple = this.contextHistoryUpdates.get(messageIndex)
|
||||
if (!innerTuple) {
|
||||
continue
|
||||
}
|
||||
|
||||
// because we are altering this, we need a deep copy
|
||||
messagesToUpdate[arrayIndex] = cloneDeep(messagesToUpdate[arrayIndex])
|
||||
|
||||
// Extract the map from the tuple
|
||||
const innerMap = innerTuple[1]
|
||||
for (const [blockIndex, changes] of innerMap) {
|
||||
// apply the latest change among n changes - [timestamp, updateType, update]
|
||||
const latestChange = changes[changes.length - 1]
|
||||
|
||||
if (latestChange[1] === "text") {
|
||||
// only altering text for now
|
||||
const message = messagesToUpdate[arrayIndex]
|
||||
|
||||
if (Array.isArray(message.content)) {
|
||||
const block = message.content[blockIndex]
|
||||
if (block && block.type === "text") {
|
||||
block.text = latestChange[2][0]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return messagesToUpdate
|
||||
}
|
||||
|
||||
/**
|
||||
* removes all context history updates that occurred after the specified timestamp and saves to disk
|
||||
*/
|
||||
async truncateContextHistory(timestamp: number, taskDirectory: string): Promise<void> {
|
||||
this.truncateContextHistoryAtTimestamp(this.contextHistoryUpdates, timestamp)
|
||||
|
||||
// save the modified context history to disk
|
||||
await this.saveContextHistory(taskDirectory)
|
||||
}
|
||||
|
||||
/**
|
||||
* alters the context history to remove all alterations after a given timestamp
|
||||
* removes the index if there are no alterations there anymore, both outer and inner indices
|
||||
*/
|
||||
private truncateContextHistoryAtTimestamp(
|
||||
contextHistory: Map<number, [number, Map<number, ContextUpdate[]>]>,
|
||||
timestamp: number,
|
||||
): void {
|
||||
for (const [messageIndex, [_, innerMap]] of contextHistory) {
|
||||
// track which blockIndices to delete
|
||||
const blockIndicesToDelete: number[] = []
|
||||
|
||||
// loop over the innerIndices of the messages in this block
|
||||
for (const [blockIndex, updates] of innerMap) {
|
||||
// updates ordered by timestamp, so find cutoff point by iterating from right to left
|
||||
let cutoffIndex = updates.length - 1
|
||||
while (cutoffIndex >= 0 && updates[cutoffIndex][0] > timestamp) {
|
||||
cutoffIndex--
|
||||
}
|
||||
|
||||
// If we found updates to remove
|
||||
if (cutoffIndex < updates.length - 1) {
|
||||
// Modify the array in place to keep only updates up to cutoffIndex
|
||||
updates.length = cutoffIndex + 1
|
||||
|
||||
// If no updates left after truncation, mark this block for deletion
|
||||
if (updates.length === 0) {
|
||||
blockIndicesToDelete.push(blockIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove empty blocks from inner map
|
||||
for (const blockIndex of blockIndicesToDelete) {
|
||||
innerMap.delete(blockIndex)
|
||||
}
|
||||
|
||||
// If inner map is now empty, remove the message index from outer map
|
||||
if (innerMap.size === 0) {
|
||||
contextHistory.delete(messageIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* applies the context optimization steps and returns whether any changes were made
|
||||
*/
|
||||
private applyContextOptimizations(
|
||||
apiMessages: Anthropic.Messages.MessageParam[],
|
||||
startFromIndex: number,
|
||||
timestamp: number,
|
||||
): [boolean, Set<number>] {
|
||||
const [fileReadUpdatesBool, uniqueFileReadIndices] = this.findAndPotentiallySaveFileReadContextHistoryUpdates(
|
||||
apiMessages,
|
||||
startFromIndex,
|
||||
timestamp,
|
||||
)
|
||||
|
||||
// true if any context optimization steps alter state
|
||||
const contextHistoryUpdated = fileReadUpdatesBool
|
||||
|
||||
return [contextHistoryUpdated, uniqueFileReadIndices]
|
||||
}
|
||||
|
||||
/**
|
||||
* if there is any truncation and there is no other alteration already set, alter the assistant message to indicate this occurred
|
||||
*/
|
||||
private applyStandardContextTruncationNoticeChange(timestamp: number): boolean {
|
||||
if (!this.contextHistoryUpdates.has(1)) {
|
||||
// first assistant message always at index 1
|
||||
const innerMap = new Map<number, ContextUpdate[]>()
|
||||
innerMap.set(0, [[timestamp, "text", [formatResponse.contextTruncationNotice()], []]])
|
||||
this.contextHistoryUpdates.set(1, [0, innerMap]) // EditType is undefined for first assistant message
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* wraps the logic for determining file reads to overwrite, and altering state
|
||||
* returns whether any updates were made (bool) and indices where updates were made
|
||||
*/
|
||||
private findAndPotentiallySaveFileReadContextHistoryUpdates(
|
||||
apiMessages: Anthropic.Messages.MessageParam[],
|
||||
startFromIndex: number,
|
||||
timestamp: number,
|
||||
): [boolean, Set<number>] {
|
||||
const [fileReadIndices, messageFilePaths] = this.getPossibleDuplicateFileReads(apiMessages, startFromIndex)
|
||||
return this.applyFileReadContextHistoryUpdates(fileReadIndices, messageFilePaths, apiMessages, timestamp)
|
||||
}
|
||||
|
||||
/**
|
||||
* generate a mapping from unique file reads from multiple tool calls to their outer index position(s)
|
||||
* also return additional metadata to support multiple file reads in file mention text blocks
|
||||
*/
|
||||
private getPossibleDuplicateFileReads(
|
||||
apiMessages: Anthropic.Messages.MessageParam[],
|
||||
startFromIndex: number,
|
||||
): [Map<string, [number, number, string, string][]>, Map<number, string[]>] {
|
||||
// fileReadIndices: { fileName => [outerIndex, EditType, searchText, replaceText] }
|
||||
// messageFilePaths: { outerIndex => [fileRead1, fileRead2, ..] }
|
||||
// searchText in fileReadIndices is only required for file mention file-reads since there can be more than one file in the text
|
||||
// searchText will be the empty string "" in the case that it's not required, for non-file mentions
|
||||
// messageFilePaths is only used for file mentions as there can be multiple files read in the same text chunk
|
||||
|
||||
// for all text blocks per file, has info for updating the block
|
||||
const fileReadIndices = new Map<string, [number, number, string, string][]>()
|
||||
|
||||
// for file mention text blocks, track all the unique files read
|
||||
const messageFilePaths = new Map<number, string[]>()
|
||||
|
||||
for (let i = startFromIndex; i < apiMessages.length; i++) {
|
||||
let thisExistingFileReads: string[] = []
|
||||
|
||||
if (this.contextHistoryUpdates.has(i)) {
|
||||
const innerTuple = this.contextHistoryUpdates.get(i)
|
||||
|
||||
if (innerTuple) {
|
||||
// safety check
|
||||
const editType = innerTuple[0]
|
||||
|
||||
if (editType === EditType.FILE_MENTION) {
|
||||
const innerMap = innerTuple[1]
|
||||
|
||||
const blockIndex = 1 // file mention blocks assumed to be at index 1
|
||||
const blockUpdates = innerMap.get(blockIndex)
|
||||
|
||||
// if we have updated this text previously, we want to check whether the lists of files in the metadata are the same
|
||||
if (blockUpdates && blockUpdates.length > 0) {
|
||||
// the first list indicates the files we have replaced in this text, second list indicates all unique files in this text
|
||||
// if they are equal then we have replaced all the files in this text already, and can ignore further processing
|
||||
if (
|
||||
blockUpdates[blockUpdates.length - 1][3][0].length ===
|
||||
blockUpdates[blockUpdates.length - 1][3][1].length
|
||||
) {
|
||||
continue
|
||||
}
|
||||
// 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]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// for all other cases we can assume that we dont need to check this again
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const message = apiMessages[i]
|
||||
if (message.role === "user" && Array.isArray(message.content) && message.content.length > 0) {
|
||||
const firstBlock = message.content[0]
|
||||
if (firstBlock.type === "text") {
|
||||
const matchTup = this.parsePotentialToolCall(firstBlock.text)
|
||||
let foundNormalFileRead = false
|
||||
if (matchTup) {
|
||||
if (matchTup[0] === "read_file") {
|
||||
this.handleReadFileToolCall(i, matchTup[1], fileReadIndices)
|
||||
foundNormalFileRead = true
|
||||
} else if (matchTup[0] === "replace_in_file" || matchTup[0] === "write_to_file") {
|
||||
if (message.content.length > 1) {
|
||||
const secondBlock = message.content[1]
|
||||
if (secondBlock.type === "text") {
|
||||
this.handlePotentialFileChangeToolCalls(i, matchTup[1], secondBlock.text, fileReadIndices)
|
||||
foundNormalFileRead = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// file mentions can happen in most other user message blocks
|
||||
if (!foundNormalFileRead) {
|
||||
if (message.content.length > 1) {
|
||||
const secondBlock = message.content[1]
|
||||
if (secondBlock.type === "text") {
|
||||
const [hasFileRead, filePaths] = this.handlePotentialFileMentionCalls(
|
||||
i,
|
||||
secondBlock.text,
|
||||
fileReadIndices,
|
||||
thisExistingFileReads, // file reads we've already replaced in this text in the latest version of this updated text
|
||||
)
|
||||
if (hasFileRead) {
|
||||
messageFilePaths.set(i, filePaths) // all file paths in this string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [fileReadIndices, messageFilePaths]
|
||||
}
|
||||
|
||||
/**
|
||||
* handles potential file content mentions in text blocks
|
||||
* there will not be more than one of the same file read in a text block
|
||||
*/
|
||||
private handlePotentialFileMentionCalls(
|
||||
i: number,
|
||||
secondBlockText: string,
|
||||
fileReadIndices: Map<string, [number, number, string, string][]>,
|
||||
thisExistingFileReads: string[],
|
||||
): [boolean, string[]] {
|
||||
const pattern = new RegExp(`<file_content path="([^"]*)">([\\s\\S]*?)</file_content>`, "g")
|
||||
|
||||
let foundMatch = false
|
||||
const filePaths: string[] = []
|
||||
|
||||
let match
|
||||
while ((match = pattern.exec(secondBlockText)) !== null) {
|
||||
foundMatch = true
|
||||
|
||||
const filePath = match[1]
|
||||
filePaths.push(filePath) // we will record all unique paths from file mentions in this text
|
||||
|
||||
// we can assume that thisExistingFileReads does not have many entries
|
||||
if (!thisExistingFileReads.includes(filePath)) {
|
||||
// meaning we havent already replaced this file read
|
||||
|
||||
const entireMatch = match[0] // The entire matched string
|
||||
|
||||
// Create the replacement text - keep the tags but replace the content
|
||||
const replacementText = `<file_content path="${filePath}">${formatResponse.duplicateFileReadNotice()}</file_content>`
|
||||
|
||||
const indices = fileReadIndices.get(filePath) || []
|
||||
indices.push([i, EditType.FILE_MENTION, entireMatch, replacementText])
|
||||
fileReadIndices.set(filePath, indices)
|
||||
}
|
||||
}
|
||||
|
||||
return [foundMatch, filePaths]
|
||||
}
|
||||
|
||||
/**
|
||||
* parses specific tool call formats, returns null if no acceptable format is found
|
||||
*/
|
||||
private parsePotentialToolCall(text: string): [string, string] | null {
|
||||
const match = text.match(/^\[([^\s]+) for '([^']+)'\] Result:$/)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
return [match[1], match[2]]
|
||||
}
|
||||
|
||||
/**
|
||||
* file_read tool call always pastes the file, so this is always a hit
|
||||
*/
|
||||
private handleReadFileToolCall(
|
||||
i: number,
|
||||
filePath: string,
|
||||
fileReadIndices: Map<string, [number, number, string, string][]>,
|
||||
) {
|
||||
const indices = fileReadIndices.get(filePath) || []
|
||||
indices.push([i, EditType.READ_FILE_TOOL, "", formatResponse.duplicateFileReadNotice()])
|
||||
fileReadIndices.set(filePath, indices)
|
||||
}
|
||||
|
||||
/**
|
||||
* write_to_file and replace_in_file tool output are handled similarly
|
||||
*/
|
||||
private handlePotentialFileChangeToolCalls(
|
||||
i: number,
|
||||
filePath: string,
|
||||
secondBlockText: string,
|
||||
fileReadIndices: Map<string, [number, number, string, string][]>,
|
||||
) {
|
||||
const pattern = new RegExp(`(<final_file_content path="[^"]*">)[\\s\\S]*?(</final_file_content>)`)
|
||||
|
||||
// check if this exists in the text, it wont exist if the user rejects the file change for example
|
||||
if (pattern.test(secondBlockText)) {
|
||||
const replacementText = secondBlockText.replace(pattern, `$1 ${formatResponse.duplicateFileReadNotice()} $2`)
|
||||
const indices = fileReadIndices.get(filePath) || []
|
||||
indices.push([i, EditType.ALTER_FILE_TOOL, "", replacementText])
|
||||
fileReadIndices.set(filePath, indices)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* alter all occurrences of file read operations and track which messages were updated
|
||||
* returns the outer index of messages we alter, to count number of changes
|
||||
*/
|
||||
private applyFileReadContextHistoryUpdates(
|
||||
fileReadIndices: Map<string, [number, number, string, string][]>,
|
||||
messageFilePaths: Map<number, string[]>,
|
||||
apiMessages: Anthropic.Messages.MessageParam[],
|
||||
timestamp: number,
|
||||
): [boolean, Set<number>] {
|
||||
let didUpdate = false
|
||||
const updatedMessageIndices = new Set<number>() // track which messages we update on this round
|
||||
const fileMentionUpdates = new Map<number, [string, string[]]>()
|
||||
|
||||
for (const [filePath, indices] of fileReadIndices.entries()) {
|
||||
// Only process if there are multiple reads of the same file, else we will want to keep the latest read of the file
|
||||
if (indices.length > 1) {
|
||||
// Process all but the last index, as we will keep that instance of the file read
|
||||
for (let i = 0; i < indices.length - 1; i++) {
|
||||
const messageIndex = indices[i][0]
|
||||
const messageType = indices[i][1] // EditType value
|
||||
const searchText = indices[i][2] // search text (for file mentions, else empty string)
|
||||
const messageString = indices[i][3] // what we will replace the string with
|
||||
|
||||
didUpdate = true
|
||||
updatedMessageIndices.add(messageIndex)
|
||||
|
||||
// for single-fileread text we can set the updates here
|
||||
// for potential multi-fileread text we need to determine all changes & iteratively update the text prior to saving the final change
|
||||
if (messageType === EditType.FILE_MENTION) {
|
||||
if (!fileMentionUpdates.has(messageIndex)) {
|
||||
// Get base text either from existing updates or from apiMessages
|
||||
let baseText = ""
|
||||
let prevFilesReplaced: string[] = []
|
||||
|
||||
const innerTuple = this.contextHistoryUpdates.get(messageIndex)
|
||||
if (innerTuple) {
|
||||
const blockUpdates = innerTuple[1].get(1) // assumed index=1 for file mention filereads
|
||||
if (blockUpdates && blockUpdates.length > 0) {
|
||||
baseText = blockUpdates[blockUpdates.length - 1][2][0] // index 0 of MessageContent
|
||||
prevFilesReplaced = blockUpdates[blockUpdates.length - 1][3][0] // previously overwritten file reads in this text
|
||||
}
|
||||
}
|
||||
|
||||
// can assume that this content will exist, otherwise it would not have been in fileReadIndices
|
||||
const messageContent = apiMessages[messageIndex]?.content
|
||||
if (!baseText && Array.isArray(messageContent) && messageContent.length > 1) {
|
||||
const contentBlock = messageContent[1] // assume index=1 for all text to replace for file mention filereads
|
||||
if (contentBlock.type === "text") {
|
||||
baseText = contentBlock.text
|
||||
}
|
||||
}
|
||||
|
||||
// prevFilesReplaced keeps track of the previous file reads we've replace in this string, empty array if none
|
||||
fileMentionUpdates.set(messageIndex, [baseText, prevFilesReplaced])
|
||||
}
|
||||
|
||||
// Replace searchText with messageString for all file reads we need to replace in this text
|
||||
if (searchText) {
|
||||
const currentTuple = fileMentionUpdates.get(messageIndex) || ["", []]
|
||||
if (currentTuple[0]) {
|
||||
// safety check
|
||||
// replace this text chunk
|
||||
const updatedText = currentTuple[0].replace(searchText, messageString)
|
||||
|
||||
// add the newly added filePath read
|
||||
const updatedFileReads = currentTuple[1]
|
||||
updatedFileReads.push(filePath)
|
||||
|
||||
fileMentionUpdates.set(messageIndex, [updatedText, updatedFileReads])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let innerTuple = this.contextHistoryUpdates.get(messageIndex)
|
||||
let innerMap: Map<number, ContextUpdate[]>
|
||||
|
||||
if (!innerTuple) {
|
||||
innerMap = new Map<number, ContextUpdate[]>()
|
||||
this.contextHistoryUpdates.set(messageIndex, [messageType, innerMap])
|
||||
} else {
|
||||
innerMap = innerTuple[1]
|
||||
}
|
||||
|
||||
// block index for file reads from read_file, write_to_file, replace_in_file tools is 1
|
||||
const blockIndex = 1
|
||||
|
||||
const updates = innerMap.get(blockIndex) || []
|
||||
|
||||
// metadata array is empty for non-file mention occurrences
|
||||
updates.push([timestamp, "text", [messageString], []])
|
||||
|
||||
innerMap.set(blockIndex, updates)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// apply file mention updates to contextHistoryUpdates
|
||||
// in fileMentionUpdates, filePathsUpdated includes all the file paths which are updated in the latest version of this altered text
|
||||
for (const [messageIndex, [updatedText, filePathsUpdated]] of fileMentionUpdates.entries()) {
|
||||
let innerTuple = this.contextHistoryUpdates.get(messageIndex)
|
||||
let innerMap: Map<number, ContextUpdate[]>
|
||||
|
||||
if (!innerTuple) {
|
||||
innerMap = new Map<number, ContextUpdate[]>()
|
||||
this.contextHistoryUpdates.set(messageIndex, [EditType.FILE_MENTION, innerMap])
|
||||
} else {
|
||||
innerMap = innerTuple[1]
|
||||
}
|
||||
|
||||
const blockIndex = 1 // we only consider the block index of 1 for file mentions
|
||||
const updates = innerMap.get(blockIndex) || []
|
||||
|
||||
// filePathsUpdated includes changes done previously to this timestamp, and right now
|
||||
if (messageFilePaths.has(messageIndex)) {
|
||||
const allFileReads = messageFilePaths.get(messageIndex)
|
||||
if (allFileReads) {
|
||||
// safety check
|
||||
// we gather all the file reads possible in this text from messageFilePaths
|
||||
// filePathsUpdated from fileMentionUpdates stores all the files reads we have replaced now & previously
|
||||
updates.push([timestamp, "text", [updatedText], [filePathsUpdated, allFileReads]])
|
||||
innerMap.set(blockIndex, updates)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [didUpdate, updatedMessageIndices]
|
||||
}
|
||||
|
||||
/**
|
||||
* count total characters in messages and total savings within this range
|
||||
*/
|
||||
private countCharactersAndSavingsInRange(
|
||||
apiMessages: Anthropic.Messages.MessageParam[],
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
uniqueFileReadIndices: Set<number>,
|
||||
): { totalCharacters: number; charactersSaved: number } {
|
||||
let totalCharCount = 0
|
||||
let totalCharactersSaved = 0
|
||||
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
// looping over the outer indicies of messages
|
||||
const message = apiMessages[i]
|
||||
|
||||
if (!message.content) {
|
||||
continue
|
||||
}
|
||||
|
||||
// hasExistingAlterations checks whether the outer idnex has any changes
|
||||
// hasExistingAlterations will also include the alterations we just made
|
||||
const hasExistingAlterations = this.contextHistoryUpdates.has(i)
|
||||
const hasNewAlterations = uniqueFileReadIndices.has(i)
|
||||
|
||||
if (Array.isArray(message.content)) {
|
||||
for (let blockIndex = 0; blockIndex < message.content.length; blockIndex++) {
|
||||
// looping over inner indices of messages
|
||||
const block = message.content[blockIndex]
|
||||
|
||||
if (block.type === "text" && block.text) {
|
||||
// true if we just altered it, or it was altered before
|
||||
if (hasExistingAlterations) {
|
||||
const innerTuple = this.contextHistoryUpdates.get(i)
|
||||
const updates = innerTuple?.[1].get(blockIndex) // updated text for this inner index
|
||||
|
||||
if (updates && updates.length > 0) {
|
||||
// exists if we have an update for the message at this index
|
||||
const latestUpdate = updates[updates.length - 1]
|
||||
|
||||
// if block was just altered, then calculate savings
|
||||
if (hasNewAlterations) {
|
||||
let originalTextLength
|
||||
if (updates.length > 1) {
|
||||
originalTextLength = updates[updates.length - 2][2][0].length // handles case if we have multiple updates for same text block
|
||||
} else {
|
||||
originalTextLength = block.text.length
|
||||
}
|
||||
|
||||
const newTextLength = latestUpdate[2][0].length // replacement text
|
||||
totalCharactersSaved += originalTextLength - newTextLength
|
||||
|
||||
totalCharCount += originalTextLength
|
||||
} else {
|
||||
// meaning there was an update to this text previously, but we didnt just alter it
|
||||
totalCharCount += latestUpdate[2][0].length
|
||||
}
|
||||
} else {
|
||||
// reach here if there was one inner index with an update, but now we are at a different index, so updates is not defined
|
||||
totalCharCount += block.text.length
|
||||
}
|
||||
} else {
|
||||
// reach here if there's no alterations for this outer index, meaning each inner index wont have any changes either
|
||||
totalCharCount += block.text.length
|
||||
}
|
||||
} else if (block.type === "image" && block.source) {
|
||||
if (block.source.type === "base64" && block.source.data) {
|
||||
totalCharCount += block.source.data.length
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { totalCharacters: totalCharCount, charactersSaved: totalCharactersSaved }
|
||||
}
|
||||
|
||||
/**
|
||||
* count total percentage character savings across in-range conversation
|
||||
*/
|
||||
private calculateContextOptimizationMetrics(
|
||||
apiMessages: Anthropic.Messages.MessageParam[],
|
||||
conversationHistoryDeletedRange: [number, number] | undefined,
|
||||
uniqueFileReadIndices: Set<number>,
|
||||
): number {
|
||||
// count for first user-assistant message pair
|
||||
const firstChunkResult = this.countCharactersAndSavingsInRange(apiMessages, 0, 2, uniqueFileReadIndices)
|
||||
|
||||
// count for the remaining in-range messages
|
||||
const secondChunkResult = this.countCharactersAndSavingsInRange(
|
||||
apiMessages,
|
||||
conversationHistoryDeletedRange ? conversationHistoryDeletedRange[1] + 1 : 2,
|
||||
apiMessages.length,
|
||||
uniqueFileReadIndices,
|
||||
)
|
||||
|
||||
const totalCharacters = firstChunkResult.totalCharacters + secondChunkResult.totalCharacters
|
||||
const totalCharactersSaved = firstChunkResult.charactersSaved + secondChunkResult.charactersSaved
|
||||
|
||||
const percentCharactersSaved = totalCharacters === 0 ? 0 : totalCharactersSaved / totalCharacters
|
||||
|
||||
return percentCharactersSaved
|
||||
}
|
||||
}
|
||||
+4
-22
@@ -1,7 +1,7 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { ClineApiReqInfo, ClineMessage } from "../../shared/ExtensionMessage"
|
||||
import { ApiHandler } from "../../api"
|
||||
import { OpenAiHandler } from "../../api/providers/openai"
|
||||
import { ClineApiReqInfo, ClineMessage } from "../../../shared/ExtensionMessage"
|
||||
import { ApiHandler } from "../../../api"
|
||||
import { getContextWindowInfo } from "./context-window-utils"
|
||||
|
||||
export class ContextManager {
|
||||
getNewContextMessagesAndMetadata(
|
||||
@@ -19,25 +19,7 @@ export class ContextManager {
|
||||
if (previousRequest && previousRequest.text) {
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
|
||||
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
let contextWindow = api.getModel().info.contextWindow || 128_000
|
||||
// FIXME: hack to get anyone using openai compatible with deepseek to have the proper context window instead of the default 128k. We need a way for the user to specify the context window for models they input through openai compatible
|
||||
if (api instanceof OpenAiHandler && api.getModel().id.toLowerCase().includes("deepseek")) {
|
||||
contextWindow = 64_000
|
||||
}
|
||||
let maxAllowedSize: number
|
||||
switch (contextWindow) {
|
||||
case 64_000: // deepseek models
|
||||
maxAllowedSize = contextWindow - 27_000
|
||||
break
|
||||
case 128_000: // most models
|
||||
maxAllowedSize = contextWindow - 30_000
|
||||
break
|
||||
case 200_000: // claude models
|
||||
maxAllowedSize = contextWindow - 40_000
|
||||
break
|
||||
default:
|
||||
maxAllowedSize = Math.max(contextWindow - 40_000, contextWindow * 0.8) // for deepseek, 80% of 64k meant only ~10k buffer which was too small and resulted in users getting context window errors.
|
||||
}
|
||||
const { maxAllowedSize } = getContextWindowInfo(api)
|
||||
|
||||
// This is the most reliable way to know when we're close to hitting the context window.
|
||||
if (totalTokens >= maxAllowedSize) {
|
||||
@@ -0,0 +1,155 @@
|
||||
import { ContextManager } from "../ContextManager"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { expect } from "chai"
|
||||
|
||||
describe("ContextManager", () => {
|
||||
function createMessages(count: number): Anthropic.Messages.MessageParam[] {
|
||||
const messages: Anthropic.Messages.MessageParam[] = []
|
||||
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: "Initial task message",
|
||||
})
|
||||
|
||||
let role: "user" | "assistant" = "assistant"
|
||||
for (let i = 1; i < count; i++) {
|
||||
messages.push({
|
||||
role,
|
||||
content: `Message ${i}`,
|
||||
})
|
||||
role = role === "user" ? "assistant" : "user"
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
describe("getNextTruncationRange", () => {
|
||||
let contextManager: ContextManager
|
||||
|
||||
beforeEach(() => {
|
||||
contextManager = new ContextManager()
|
||||
})
|
||||
|
||||
it("first truncation with half keep", () => {
|
||||
const messages = createMessages(11)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
|
||||
|
||||
expect(result).to.deep.equal([1, 4])
|
||||
})
|
||||
|
||||
it("first truncation with quarter keep", () => {
|
||||
const messages = createMessages(11)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "quarter")
|
||||
|
||||
expect(result).to.deep.equal([1, 6])
|
||||
})
|
||||
|
||||
it("sequential truncation with half keep", () => {
|
||||
const messages = createMessages(21)
|
||||
const firstRange = contextManager.getNextTruncationRange(messages, undefined, "half")
|
||||
expect(firstRange).to.deep.equal([1, 10])
|
||||
|
||||
// Pass the previous range for sequential truncation
|
||||
const secondRange = contextManager.getNextTruncationRange(messages, firstRange, "half")
|
||||
expect(secondRange).to.deep.equal([1, 14])
|
||||
})
|
||||
|
||||
it("sequential truncation with quarter keep", () => {
|
||||
const messages = createMessages(41)
|
||||
const firstRange = contextManager.getNextTruncationRange(messages, undefined, "quarter")
|
||||
|
||||
const secondRange = contextManager.getNextTruncationRange(messages, firstRange, "quarter")
|
||||
|
||||
expect(secondRange[0]).to.equal(1)
|
||||
expect(secondRange[1]).to.be.greaterThan(firstRange[1])
|
||||
})
|
||||
|
||||
it("ensures the last message in range is a user message", () => {
|
||||
const messages = createMessages(14)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
|
||||
|
||||
// Check if the message at the end of range is a user message
|
||||
const lastRemovedMessage = messages[result[1]]
|
||||
expect(lastRemovedMessage.role).to.equal("user")
|
||||
|
||||
// Check if the next message after the range is an assistant message
|
||||
const nextMessage = messages[result[1] + 1]
|
||||
expect(nextMessage.role).to.equal("assistant")
|
||||
})
|
||||
|
||||
it("handles small message arrays", () => {
|
||||
const messages = createMessages(3)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
|
||||
|
||||
expect(result).to.deep.equal([1, 0])
|
||||
})
|
||||
|
||||
it("preserves the message structure when truncating", () => {
|
||||
const messages = createMessages(20)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
|
||||
|
||||
// Get messages after removing the range
|
||||
const effectiveMessages = [...messages.slice(0, result[0]), ...messages.slice(result[1] + 1)]
|
||||
|
||||
// Check first message and alternating pattern
|
||||
expect(effectiveMessages[0].role).to.equal("user")
|
||||
for (let i = 1; i < effectiveMessages.length; i++) {
|
||||
const expectedRole = i % 2 === 1 ? "assistant" : "user"
|
||||
expect(effectiveMessages[i].role).to.equal(expectedRole)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTruncatedMessages", () => {
|
||||
let contextManager: ContextManager
|
||||
|
||||
beforeEach(() => {
|
||||
contextManager = new ContextManager()
|
||||
})
|
||||
|
||||
it("returns original messages when no range is provided", () => {
|
||||
const messages = createMessages(3)
|
||||
|
||||
const result = contextManager.getTruncatedMessages(messages, undefined)
|
||||
expect(result).to.deep.equal(messages)
|
||||
})
|
||||
|
||||
it("correctly removes messages in the specified range", () => {
|
||||
const messages = createMessages(5)
|
||||
|
||||
const range: [number, number] = [1, 3]
|
||||
const result = contextManager.getTruncatedMessages(messages, range)
|
||||
|
||||
expect(result).to.have.lengthOf(2)
|
||||
expect(result[0]).to.deep.equal(messages[0])
|
||||
expect(result[1]).to.deep.equal(messages[4])
|
||||
})
|
||||
|
||||
it("works with a range that starts at the first message after task", () => {
|
||||
const messages = createMessages(4)
|
||||
|
||||
const range: [number, number] = [1, 2]
|
||||
const result = contextManager.getTruncatedMessages(messages, range)
|
||||
|
||||
expect(result).to.have.lengthOf(2)
|
||||
expect(result[0]).to.deep.equal(messages[0])
|
||||
expect(result[1]).to.deep.equal(messages[3])
|
||||
})
|
||||
|
||||
it("correctly handles removing a range while preserving alternation pattern", () => {
|
||||
const messages = createMessages(5)
|
||||
|
||||
const range: [number, number] = [1, 2]
|
||||
const result = contextManager.getTruncatedMessages(messages, range)
|
||||
|
||||
expect(result).to.have.lengthOf(3)
|
||||
expect(result[0]).to.deep.equal(messages[0])
|
||||
expect(result[1]).to.deep.equal(messages[3])
|
||||
expect(result[2]).to.deep.equal(messages[4])
|
||||
|
||||
expect(result[0].role).to.equal("user")
|
||||
expect(result[1].role).to.equal("assistant")
|
||||
expect(result[2].role).to.equal("user")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
export function checkIsOpenRouterContextWindowError(error: any): boolean {
|
||||
try {
|
||||
return error.code === 400 && error.message?.includes("context length")
|
||||
} catch (e: unknown) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function checkIsAnthropicContextWindowError(response: any): boolean {
|
||||
try {
|
||||
return (
|
||||
response?.error?.error?.type === "invalid_request_error" &&
|
||||
response?.error?.error?.message?.includes("prompt is too long")
|
||||
)
|
||||
} catch (e: unknown) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ApiHandler } from "../../../api"
|
||||
import { OpenAiHandler } from "../../../api/providers/openai"
|
||||
|
||||
/**
|
||||
* Gets context window information for the given API handler
|
||||
*
|
||||
* @param api The API handler to get context window information for
|
||||
* @returns An object containing the raw context window size and the effective max allowed size
|
||||
*/
|
||||
export function getContextWindowInfo(api: ApiHandler) {
|
||||
let contextWindow = api.getModel().info.contextWindow || 128_000
|
||||
// FIXME: hack to get anyone using openai compatible with deepseek to have the proper context window instead of the default 128k. We need a way for the user to specify the context window for models they input through openai compatible
|
||||
|
||||
// Handle special cases like DeepSeek
|
||||
if (api instanceof OpenAiHandler && api.getModel().id.toLowerCase().includes("deepseek")) {
|
||||
contextWindow = 64_000
|
||||
}
|
||||
|
||||
let maxAllowedSize: number
|
||||
switch (contextWindow) {
|
||||
case 64_000: // deepseek models
|
||||
maxAllowedSize = contextWindow - 27_000
|
||||
break
|
||||
case 128_000: // most models
|
||||
maxAllowedSize = contextWindow - 30_000
|
||||
break
|
||||
case 200_000: // claude models
|
||||
maxAllowedSize = contextWindow - 40_000
|
||||
break
|
||||
default:
|
||||
maxAllowedSize = Math.max(contextWindow - 40_000, contextWindow * 0.8) // for deepseek, 80% of 64k meant only ~10k buffer which was too small and resulted in users getting context window errors.
|
||||
}
|
||||
|
||||
return { contextWindow, maxAllowedSize }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as vscode from "vscode"
|
||||
|
||||
// Type definitions for FileContextTracker
|
||||
export interface FileMetadataEntry {
|
||||
path: string
|
||||
record_state: "active" | "stale"
|
||||
record_source: "read_tool" | "user_edited" | "cline_edited" | "file_mentioned"
|
||||
cline_read_date: number | null
|
||||
cline_edit_date: number | null
|
||||
user_edit_date?: number | null
|
||||
}
|
||||
|
||||
export interface ModelMetadataEntry {
|
||||
ts: number
|
||||
model_id: string
|
||||
model_provider_id: string
|
||||
mode: string
|
||||
}
|
||||
|
||||
export interface TaskMetadata {
|
||||
files_in_context: FileMetadataEntry[]
|
||||
model_usage: ModelMetadataEntry[]
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import { expect } from "chai"
|
||||
import * as sinon from "sinon"
|
||||
import * as vscode from "vscode"
|
||||
import * as path from "path"
|
||||
import { FileContextTracker } from "./FileContextTracker"
|
||||
import * as diskModule from "../../storage/disk"
|
||||
import type { TaskMetadata, FileMetadataEntry } from "./ContextTrackerTypes"
|
||||
|
||||
describe("FileContextTracker", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let mockContext: vscode.ExtensionContext
|
||||
let mockWorkspace: sinon.SinonStub
|
||||
let mockFileSystemWatcher: any
|
||||
let tracker: FileContextTracker
|
||||
let taskId: string
|
||||
let mockTaskMetadata: TaskMetadata
|
||||
let getTaskMetadataStub: sinon.SinonStub
|
||||
let saveTaskMetadataStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
|
||||
// Mock vscode workspace
|
||||
mockWorkspace = sandbox.stub(vscode.workspace, "workspaceFolders").value([
|
||||
{
|
||||
uri: {
|
||||
fsPath: "/mock/workspace",
|
||||
},
|
||||
} as vscode.WorkspaceFolder,
|
||||
])
|
||||
|
||||
// Mock file system watcher
|
||||
mockFileSystemWatcher = {
|
||||
dispose: sandbox.stub(),
|
||||
onDidChange: sandbox.stub().returns({ dispose: () => {} }),
|
||||
}
|
||||
|
||||
// Use a function replacement instead of a direct stub
|
||||
const originalCreateFileSystemWatcher = vscode.workspace.createFileSystemWatcher
|
||||
vscode.workspace.createFileSystemWatcher = function () {
|
||||
return mockFileSystemWatcher
|
||||
}
|
||||
|
||||
// Mock controller and context
|
||||
mockContext = {
|
||||
globalStorageUri: { fsPath: "/mock/storage" },
|
||||
} as unknown as vscode.ExtensionContext
|
||||
|
||||
// Mock disk module functions
|
||||
mockTaskMetadata = { files_in_context: [], model_usage: [] }
|
||||
getTaskMetadataStub = sandbox.stub(diskModule, "getTaskMetadata").resolves(mockTaskMetadata)
|
||||
saveTaskMetadataStub = sandbox.stub(diskModule, "saveTaskMetadata").resolves()
|
||||
|
||||
// Create tracker instance
|
||||
taskId = "test-task-id"
|
||||
tracker = new FileContextTracker(mockContext, taskId)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
it("should add a record when a file is read by a tool", async () => {
|
||||
const filePath = "src/test-file.ts"
|
||||
|
||||
await tracker.trackFileContext(filePath, "read_tool")
|
||||
|
||||
// Verify getTaskMetadata was called
|
||||
expect(getTaskMetadataStub.calledOnce).to.be.true
|
||||
expect(getTaskMetadataStub.firstCall.args[1]).to.equal(taskId)
|
||||
|
||||
// Verify saveTaskMetadata was called with the correct data
|
||||
expect(saveTaskMetadataStub.calledOnce).to.be.true
|
||||
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
|
||||
expect(savedMetadata.files_in_context.length).to.equal(1)
|
||||
|
||||
const fileEntry = savedMetadata.files_in_context[0]
|
||||
expect(fileEntry.path).to.equal(filePath)
|
||||
expect(fileEntry.record_state).to.equal("active")
|
||||
expect(fileEntry.record_source).to.equal("read_tool")
|
||||
expect(fileEntry.cline_read_date).to.be.a("number")
|
||||
expect(fileEntry.cline_edit_date).to.be.null
|
||||
})
|
||||
|
||||
it("should add a record when a file is edited by Cline", async () => {
|
||||
const filePath = "src/test-file.ts"
|
||||
|
||||
await tracker.trackFileContext(filePath, "cline_edited")
|
||||
|
||||
// Verify saveTaskMetadata was called with the correct data
|
||||
expect(saveTaskMetadataStub.calledOnce).to.be.true
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
|
||||
|
||||
// Check that we have at least one entry in files_in_context
|
||||
expect(savedMetadata.files_in_context).to.be.an("array").that.is.not.empty
|
||||
|
||||
// Find the active entry for this file
|
||||
const activeEntry = savedMetadata.files_in_context.find(
|
||||
(entry: FileMetadataEntry) => entry.path === filePath && entry.record_state === "active",
|
||||
)
|
||||
|
||||
// Assert that we found an active entry
|
||||
expect(activeEntry).to.exist
|
||||
|
||||
// Now check the properties of the active entry
|
||||
expect(activeEntry.path).to.equal(filePath)
|
||||
expect(activeEntry.record_state).to.equal("active")
|
||||
expect(activeEntry.record_source).to.equal("cline_edited")
|
||||
expect(activeEntry.cline_read_date).to.be.a("number")
|
||||
expect(activeEntry.cline_edit_date).to.be.a("number")
|
||||
})
|
||||
|
||||
it("should add a record when a file is mentioned", async () => {
|
||||
const filePath = "src/test-file.ts"
|
||||
|
||||
await tracker.trackFileContext(filePath, "file_mentioned")
|
||||
|
||||
// Verify saveTaskMetadata was called with the correct data
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
|
||||
const fileEntry = savedMetadata.files_in_context[0]
|
||||
|
||||
expect(fileEntry.path).to.equal(filePath)
|
||||
expect(fileEntry.record_state).to.equal("active")
|
||||
expect(fileEntry.record_source).to.equal("file_mentioned")
|
||||
expect(fileEntry.cline_read_date).to.be.a("number")
|
||||
expect(fileEntry.cline_edit_date).to.be.null
|
||||
})
|
||||
|
||||
it("should add a record when a file is edited by the user", async () => {
|
||||
const filePath = "src/test-file.ts"
|
||||
|
||||
await tracker.trackFileContext(filePath, "user_edited")
|
||||
|
||||
// Verify saveTaskMetadata was called with the correct data
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
|
||||
const fileEntry = savedMetadata.files_in_context[0]
|
||||
|
||||
expect(fileEntry.path).to.equal(filePath)
|
||||
expect(fileEntry.record_state).to.equal("active")
|
||||
expect(fileEntry.record_source).to.equal("user_edited")
|
||||
expect(fileEntry.user_edit_date).to.be.a("number")
|
||||
|
||||
// Verify the file was added to recentlyModifiedFiles
|
||||
const modifiedFiles = tracker.getAndClearRecentlyModifiedFiles()
|
||||
expect(modifiedFiles).to.include(filePath)
|
||||
})
|
||||
|
||||
it("should mark existing entries as stale when adding a new entry for the same file", async () => {
|
||||
const filePath = "src/test-file.ts"
|
||||
|
||||
// Add an initial entry
|
||||
mockTaskMetadata.files_in_context = [
|
||||
{
|
||||
path: filePath,
|
||||
record_state: "active",
|
||||
record_source: "read_tool",
|
||||
cline_read_date: Date.now() - 1000, // 1 second ago
|
||||
cline_edit_date: null,
|
||||
user_edit_date: null,
|
||||
},
|
||||
]
|
||||
|
||||
// Track a new operation on the same file
|
||||
await tracker.trackFileContext(filePath, "cline_edited")
|
||||
|
||||
// Verify the metadata now has two entries - one stale and one active
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
|
||||
expect(savedMetadata.files_in_context.length).to.equal(2)
|
||||
|
||||
// First entry should be marked as stale
|
||||
expect(savedMetadata.files_in_context[0].record_state).to.equal("stale")
|
||||
|
||||
// New entry should be active
|
||||
const newEntry = savedMetadata.files_in_context[1]
|
||||
expect(newEntry.record_state).to.equal("active")
|
||||
expect(newEntry.record_source).to.equal("cline_edited")
|
||||
})
|
||||
|
||||
it("should setup a file watcher for tracked files", async () => {
|
||||
const filePath = "src/test-file.ts"
|
||||
|
||||
// Create a spy to track if createFileSystemWatcher was called
|
||||
const createWatcherSpy = sinon.spy(vscode.workspace, "createFileSystemWatcher")
|
||||
|
||||
await tracker.trackFileContext(filePath, "read_tool")
|
||||
|
||||
// Verify createFileSystemWatcher was called
|
||||
expect(createWatcherSpy.called).to.be.true
|
||||
createWatcherSpy.restore()
|
||||
|
||||
// Verify onDidChange was called to set up the change listener
|
||||
expect(mockFileSystemWatcher.onDidChange.called).to.be.true
|
||||
})
|
||||
|
||||
it("should track user edits when file watcher detects changes", async () => {
|
||||
const filePath = "src/test-file.ts"
|
||||
|
||||
// First track the file to set up the watcher
|
||||
await tracker.trackFileContext(filePath, "read_tool")
|
||||
|
||||
// Reset the stubs to check the next calls
|
||||
getTaskMetadataStub.resetHistory()
|
||||
saveTaskMetadataStub.resetHistory()
|
||||
|
||||
// Create a spy on trackFileContext to verify it's called with the right parameters
|
||||
const trackFileContextSpy = sandbox.spy(tracker, "trackFileContext")
|
||||
|
||||
// Get the callback that was registered with onDidChange
|
||||
const callback = mockFileSystemWatcher.onDidChange.firstCall.args[0]
|
||||
|
||||
// Directly call the callback to simulate a file change event
|
||||
callback(vscode.Uri.file(path.resolve("/mock/workspace", filePath)))
|
||||
|
||||
// Verify trackFileContext was called with the right parameters
|
||||
expect(trackFileContextSpy.calledWith(filePath, "user_edited")).to.be.true
|
||||
|
||||
// Verify the file was added to recentlyModifiedFiles
|
||||
const modifiedFiles = tracker.getAndClearRecentlyModifiedFiles()
|
||||
expect(modifiedFiles).to.include(filePath)
|
||||
})
|
||||
|
||||
it("should not track Cline edits as user edits", async () => {
|
||||
const filePath = "src/test-file.ts"
|
||||
|
||||
// First track the file to set up the watcher
|
||||
await tracker.trackFileContext(filePath, "read_tool")
|
||||
|
||||
// Mark the file as edited by Cline
|
||||
tracker.markFileAsEditedByCline(filePath)
|
||||
|
||||
// Reset the stubs to check the next calls
|
||||
getTaskMetadataStub.resetHistory()
|
||||
saveTaskMetadataStub.resetHistory()
|
||||
|
||||
// Create a spy on trackFileContext to verify it's not called
|
||||
const trackFileContextSpy = sandbox.spy(tracker, "trackFileContext")
|
||||
|
||||
// Get the callback that was registered with onDidChange
|
||||
const callback = mockFileSystemWatcher.onDidChange.firstCall.args[0]
|
||||
|
||||
// Directly call the callback to simulate a file change event
|
||||
callback(vscode.Uri.file(path.resolve("/mock/workspace", filePath)))
|
||||
|
||||
// Verify trackFileContext was not called with user_edited
|
||||
expect(trackFileContextSpy.calledWith(filePath, "user_edited")).to.be.false
|
||||
|
||||
// Verify the file was not added to recentlyModifiedFiles
|
||||
const modifiedFiles = tracker.getAndClearRecentlyModifiedFiles()
|
||||
expect(modifiedFiles).to.not.include(filePath)
|
||||
})
|
||||
|
||||
it("should dispose file watchers when dispose is called", async () => {
|
||||
const filePath = "src/test-file.ts"
|
||||
|
||||
// Track a file to set up the watcher
|
||||
await tracker.trackFileContext(filePath, "read_tool")
|
||||
|
||||
// Call dispose
|
||||
tracker.dispose()
|
||||
|
||||
// Verify the watcher was disposed
|
||||
expect(mockFileSystemWatcher.dispose.called).to.be.true
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { getTaskMetadata, saveTaskMetadata } from "../../storage/disk"
|
||||
import type { FileMetadataEntry } from "./ContextTrackerTypes"
|
||||
|
||||
// This class is responsible for tracking file operations that may result in stale context.
|
||||
// If a user modifies a file outside of Cline, the context may become stale and need to be updated.
|
||||
// We do not want Cline to reload the context every time a file is modified, so we use this class merely
|
||||
// to inform Cline that the change has occurred, and tell Cline to reload the file before making
|
||||
// any changes to it. This fixes an issue with diff editing, where Cline was unable to complete a diff edit.
|
||||
// a diff edit because the file was modified since Cline last read it.
|
||||
|
||||
// FileContextTracker
|
||||
//
|
||||
// This class is responsible for tracking file operations.
|
||||
// If the full contents of a file are pass to Cline via a tool, mention, or edit, the file is marked as active.
|
||||
// If a file is modified outside of Cline, we detect and track this change to prevent stale context.
|
||||
export class FileContextTracker {
|
||||
private context: vscode.ExtensionContext
|
||||
readonly taskId: string
|
||||
|
||||
// File tracking and watching
|
||||
private fileWatchers = new Map<string, vscode.FileSystemWatcher>()
|
||||
private recentlyModifiedFiles = new Set<string>()
|
||||
private recentlyEditedByCline = new Set<string>()
|
||||
|
||||
constructor(context: vscode.ExtensionContext, taskId: string) {
|
||||
this.context = context
|
||||
this.taskId = taskId
|
||||
}
|
||||
|
||||
// Gets the current working directory or returns undefined if it cannot be determined
|
||||
private getCwd(): string | undefined {
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
if (!cwd) {
|
||||
console.info("No workspace folder available - cannot determine current working directory")
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
// File watchers are set up for each file that is tracked in the task metadata.
|
||||
async setupFileWatcher(filePath: string) {
|
||||
// Only setup watcher if it doesn't already exist for this file
|
||||
if (this.fileWatchers.has(filePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
const cwd = this.getCwd()
|
||||
if (!cwd) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create a file system watcher for this specific file
|
||||
const fileUri = vscode.Uri.file(path.resolve(cwd, filePath))
|
||||
const watcher = vscode.workspace.createFileSystemWatcher(
|
||||
new vscode.RelativePattern(path.dirname(fileUri.fsPath), path.basename(fileUri.fsPath)),
|
||||
)
|
||||
|
||||
// Track file changes
|
||||
watcher.onDidChange(() => {
|
||||
if (this.recentlyEditedByCline.has(filePath)) {
|
||||
this.recentlyEditedByCline.delete(filePath) // This was an edit by Cline, no need to inform Cline
|
||||
} else {
|
||||
this.recentlyModifiedFiles.add(filePath) // This was a user edit, we will inform Cline
|
||||
this.trackFileContext(filePath, "user_edited") // Update the task metadata with file tracking
|
||||
}
|
||||
})
|
||||
|
||||
// Store the watcher so we can dispose it later
|
||||
this.fileWatchers.set(filePath, watcher)
|
||||
}
|
||||
|
||||
// Tracks a file operation in metadata and sets up a watcher for the file
|
||||
// This is the main entry point for FileContextTracker and is called when a file is passed to Cline via a tool, mention, or edit.
|
||||
async trackFileContext(filePath: string, operation: "read_tool" | "user_edited" | "cline_edited" | "file_mentioned") {
|
||||
try {
|
||||
const cwd = this.getCwd()
|
||||
if (!cwd) {
|
||||
return
|
||||
}
|
||||
|
||||
// Add file to metadata
|
||||
await this.addFileToFileContextTracker(this.context, this.taskId, filePath, operation)
|
||||
|
||||
// Set up file watcher for this file
|
||||
await this.setupFileWatcher(filePath)
|
||||
} catch (error) {
|
||||
console.error("Failed to track file operation:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// Adds a file to the metadata tracker
|
||||
// This handles the business logic of determining if the file is new, stale, or active.
|
||||
// It also updates the metadata with the latest read/edit dates.
|
||||
async addFileToFileContextTracker(
|
||||
context: vscode.ExtensionContext,
|
||||
taskId: string,
|
||||
filePath: string,
|
||||
source: FileMetadataEntry["record_source"],
|
||||
) {
|
||||
try {
|
||||
const metadata = await getTaskMetadata(context, taskId)
|
||||
const now = Date.now()
|
||||
|
||||
// Mark existing entries for this file as stale
|
||||
metadata.files_in_context.forEach((entry) => {
|
||||
if (entry.path === filePath && entry.record_state === "active") {
|
||||
entry.record_state = "stale"
|
||||
}
|
||||
})
|
||||
|
||||
// Helper to get the latest date for a specific field and file
|
||||
const getLatestDateForField = (path: string, field: keyof FileMetadataEntry): number | null => {
|
||||
const relevantEntries = metadata.files_in_context
|
||||
.filter((entry) => entry.path === path && entry[field])
|
||||
.sort((a, b) => (b[field] as number) - (a[field] as number))
|
||||
|
||||
return relevantEntries.length > 0 ? (relevantEntries[0][field] as number) : null
|
||||
}
|
||||
|
||||
let newEntry: FileMetadataEntry = {
|
||||
path: filePath,
|
||||
record_state: "active",
|
||||
record_source: source,
|
||||
cline_read_date: getLatestDateForField(filePath, "cline_read_date"),
|
||||
cline_edit_date: getLatestDateForField(filePath, "cline_edit_date"),
|
||||
user_edit_date: getLatestDateForField(filePath, "user_edit_date"),
|
||||
}
|
||||
|
||||
switch (source) {
|
||||
// user_edited: The user has edited the file
|
||||
case "user_edited":
|
||||
newEntry.user_edit_date = now
|
||||
this.recentlyModifiedFiles.add(filePath)
|
||||
break
|
||||
|
||||
// cline_edited: Cline has edited the file
|
||||
case "cline_edited":
|
||||
newEntry.cline_read_date = now
|
||||
newEntry.cline_edit_date = now
|
||||
break
|
||||
|
||||
// read_tool/file_mentioned: Cline has read the file via a tool or file mention
|
||||
case "read_tool":
|
||||
case "file_mentioned":
|
||||
newEntry.cline_read_date = now
|
||||
break
|
||||
}
|
||||
|
||||
metadata.files_in_context.push(newEntry)
|
||||
await saveTaskMetadata(context, taskId, metadata)
|
||||
} catch (error) {
|
||||
console.error("Failed to add file to metadata:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// Returns (and then clears) the set of recently modified files
|
||||
getAndClearRecentlyModifiedFiles(): string[] {
|
||||
const files = Array.from(this.recentlyModifiedFiles)
|
||||
this.recentlyModifiedFiles.clear()
|
||||
return files
|
||||
}
|
||||
|
||||
// Marks a file as edited by Cline to prevent false positives in file watchers
|
||||
markFileAsEditedByCline(filePath: string): void {
|
||||
this.recentlyEditedByCline.add(filePath)
|
||||
}
|
||||
|
||||
// Disposes all file watchers
|
||||
dispose(): void {
|
||||
for (const watcher of this.fileWatchers.values()) {
|
||||
watcher.dispose()
|
||||
}
|
||||
this.fileWatchers.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import { expect } from "chai"
|
||||
import * as sinon from "sinon"
|
||||
import * as vscode from "vscode"
|
||||
import { ModelContextTracker } from "./ModelContextTracker"
|
||||
import * as diskModule from "../../storage/disk"
|
||||
import type { TaskMetadata } from "./ContextTrackerTypes"
|
||||
|
||||
describe("ModelContextTracker", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let mockContext: vscode.ExtensionContext
|
||||
let tracker: ModelContextTracker
|
||||
let taskId: string
|
||||
let mockTaskMetadata: TaskMetadata
|
||||
let getTaskMetadataStub: sinon.SinonStub
|
||||
let saveTaskMetadataStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
|
||||
// Mock controller and context
|
||||
mockContext = {
|
||||
globalStorageUri: { fsPath: "/mock/storage" },
|
||||
} as unknown as vscode.ExtensionContext
|
||||
|
||||
// Mock disk module functions
|
||||
mockTaskMetadata = { files_in_context: [], model_usage: [] }
|
||||
getTaskMetadataStub = sandbox.stub(diskModule, "getTaskMetadata").resolves(mockTaskMetadata)
|
||||
saveTaskMetadataStub = sandbox.stub(diskModule, "saveTaskMetadata").resolves()
|
||||
|
||||
// Create tracker instance
|
||||
taskId = "test-task-id"
|
||||
tracker = new ModelContextTracker(mockContext, taskId)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
it("should record model usage with correct data", async () => {
|
||||
// Test data
|
||||
const apiProviderId = "anthropic"
|
||||
const modelId = "claude-3-opus"
|
||||
const mode = "act"
|
||||
|
||||
// Use a fake timer to have a predictable timestamp
|
||||
const fakeNow = 1617293940000 // Some fixed timestamp
|
||||
const clock = sandbox.useFakeTimers(fakeNow)
|
||||
|
||||
try {
|
||||
// Call the method being tested
|
||||
await tracker.recordModelUsage(apiProviderId, modelId, mode)
|
||||
|
||||
// Verify getTaskMetadata was called with correct parameters
|
||||
expect(getTaskMetadataStub.calledOnce).to.be.true
|
||||
expect(getTaskMetadataStub.firstCall.args[1]).to.equal(taskId)
|
||||
|
||||
// Verify saveTaskMetadata was called with the correct data
|
||||
expect(saveTaskMetadataStub.calledOnce).to.be.true
|
||||
|
||||
// Extract the saved metadata from the call arguments
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
|
||||
|
||||
// Verify model_usage array has one entry
|
||||
expect(savedMetadata.model_usage.length).to.equal(1)
|
||||
|
||||
// Verify the entry has the correct properties
|
||||
const modelUsageEntry = savedMetadata.model_usage[0]
|
||||
expect(modelUsageEntry.ts).to.equal(fakeNow)
|
||||
expect(modelUsageEntry.model_id).to.equal(modelId)
|
||||
expect(modelUsageEntry.model_provider_id).to.equal(apiProviderId)
|
||||
expect(modelUsageEntry.mode).to.equal(mode)
|
||||
} finally {
|
||||
// Restore the clock
|
||||
clock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw an error when controller is dereferenced", async () => {
|
||||
// Create a new tracker with a controller that will be garbage collected
|
||||
const weakTracker = new ModelContextTracker(mockContext, taskId)
|
||||
|
||||
// Force the WeakRef to return null by overriding the deref method
|
||||
const weakRef = { deref: sandbox.stub().returns(null) }
|
||||
sandbox.stub(WeakRef.prototype, "deref").callsFake(() => weakRef.deref())
|
||||
|
||||
try {
|
||||
// Try to call the method - this should throw
|
||||
await weakTracker.recordModelUsage("any-provider", "any-model", "any-mode")
|
||||
|
||||
// If we get here, the test should fail
|
||||
expect.fail("Expected an error to be thrown")
|
||||
} catch (error) {
|
||||
// Verify the error message
|
||||
expect(error.message).to.equal("Unable to access extension context")
|
||||
}
|
||||
})
|
||||
|
||||
it("should append model usage to existing entries", async () => {
|
||||
// Add an existing model usage entry
|
||||
const existingTimestamp = 1617200000000
|
||||
mockTaskMetadata.model_usage = [
|
||||
{
|
||||
ts: existingTimestamp,
|
||||
model_id: "existing-model",
|
||||
model_provider_id: "existing-provider",
|
||||
mode: "plan",
|
||||
},
|
||||
]
|
||||
|
||||
// Test data for new entry
|
||||
const apiProviderId = "anthropic"
|
||||
const modelId = "claude-3-sonnet"
|
||||
const mode = "act"
|
||||
|
||||
// Use a fake timer
|
||||
const newTimestamp = 1617300000000
|
||||
const clock = sandbox.useFakeTimers(newTimestamp)
|
||||
|
||||
try {
|
||||
// Call the method being tested
|
||||
await tracker.recordModelUsage(apiProviderId, modelId, mode)
|
||||
|
||||
// Verify saveTaskMetadata was called
|
||||
expect(saveTaskMetadataStub.calledOnce).to.be.true
|
||||
|
||||
// Extract the saved metadata
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
|
||||
|
||||
// Verify model_usage array now has two entries
|
||||
expect(savedMetadata.model_usage.length).to.equal(2)
|
||||
|
||||
// Verify the existing entry is preserved
|
||||
expect(savedMetadata.model_usage[0]).to.deep.equal({
|
||||
ts: existingTimestamp,
|
||||
model_id: "existing-model",
|
||||
model_provider_id: "existing-provider",
|
||||
mode: "plan",
|
||||
})
|
||||
|
||||
// Verify the new entry has correct data
|
||||
expect(savedMetadata.model_usage[1]).to.deep.equal({
|
||||
ts: newTimestamp,
|
||||
model_id: modelId,
|
||||
model_provider_id: apiProviderId,
|
||||
mode: mode,
|
||||
})
|
||||
} finally {
|
||||
clock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle multiple model usages in sequence", async () => {
|
||||
// Test data for sequential calls
|
||||
const usages = [
|
||||
{ provider: "anthropic", model: "claude-3-opus", mode: "plan" },
|
||||
{ provider: "openai", model: "gpt-4", mode: "act" },
|
||||
{ provider: "anthropic", model: "claude-3-haiku", mode: "plan" },
|
||||
]
|
||||
|
||||
// Use a fake timer that advances with each call
|
||||
const startTime = 1617300000000
|
||||
const clock = sandbox.useFakeTimers(startTime)
|
||||
|
||||
try {
|
||||
// Record multiple model usages
|
||||
for (let i = 0; i < usages.length; i++) {
|
||||
const { provider, model, mode } = usages[i]
|
||||
|
||||
// Advance time by 1 second for each call
|
||||
clock.tick(1000)
|
||||
const expectedTime = startTime + (i + 1) * 1000
|
||||
|
||||
// Reset history between calls to check individual call behavior
|
||||
getTaskMetadataStub.resetHistory()
|
||||
saveTaskMetadataStub.resetHistory()
|
||||
|
||||
// Reset mock metadata for each iteration to avoid accumulation
|
||||
mockTaskMetadata.model_usage = []
|
||||
|
||||
// Call the method
|
||||
await tracker.recordModelUsage(provider, model, mode)
|
||||
|
||||
// Verify interaction with disk module
|
||||
expect(getTaskMetadataStub.calledOnce).to.be.true
|
||||
expect(saveTaskMetadataStub.calledOnce).to.be.true
|
||||
|
||||
// Get the saved metadata
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
|
||||
|
||||
// Since we reset the array for each call, we should always have 1 entry
|
||||
expect(savedMetadata.model_usage.length).to.equal(1)
|
||||
|
||||
// Check the entry
|
||||
const entry = savedMetadata.model_usage[0]
|
||||
expect(entry.ts).to.equal(expectedTime)
|
||||
expect(entry.model_id).to.equal(model)
|
||||
expect(entry.model_provider_id).to.equal(provider)
|
||||
expect(entry.mode).to.equal(mode)
|
||||
}
|
||||
} finally {
|
||||
clock.restore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as vscode from "vscode"
|
||||
import { getTaskMetadata, saveTaskMetadata } from "../../storage/disk"
|
||||
|
||||
export class ModelContextTracker {
|
||||
readonly taskId: string
|
||||
private context: vscode.ExtensionContext
|
||||
|
||||
constructor(context: vscode.ExtensionContext, taskId: string) {
|
||||
this.context = context
|
||||
this.taskId = taskId
|
||||
}
|
||||
|
||||
async recordModelUsage(apiProviderId: string, modelId: string, mode: string) {
|
||||
const metadata = await getTaskMetadata(this.context, this.taskId)
|
||||
|
||||
if (!metadata.model_usage) {
|
||||
metadata.model_usage = []
|
||||
}
|
||||
|
||||
// check to see if the last entry is the same as the new one
|
||||
const lastEntry = metadata.model_usage[metadata.model_usage.length - 1]
|
||||
if (
|
||||
lastEntry &&
|
||||
lastEntry.model_id === modelId &&
|
||||
lastEntry.model_provider_id === apiProviderId &&
|
||||
lastEntry.mode === mode
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
metadata.model_usage.push({
|
||||
ts: Date.now(),
|
||||
model_id: modelId,
|
||||
model_provider_id: apiProviderId,
|
||||
mode: mode,
|
||||
})
|
||||
|
||||
await saveTaskMetadata(this.context, this.taskId, metadata)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import path from "path"
|
||||
import { GlobalFileNames } from "../../../storage/disk"
|
||||
import { fileExistsAtPath, isDirectory, readDirectory } from "../../../../utils/fs"
|
||||
import { formatResponse } from "../../../prompts/responses"
|
||||
import fs from "fs/promises"
|
||||
|
||||
export const getClineRules = async (cwd: string) => {
|
||||
const clineRulesFilePath = path.resolve(cwd, GlobalFileNames.clineRules)
|
||||
|
||||
let clineRulesFileInstructions: string | undefined
|
||||
|
||||
if (await fileExistsAtPath(clineRulesFilePath)) {
|
||||
if (await isDirectory(clineRulesFilePath)) {
|
||||
try {
|
||||
const rulesFilePaths = await readDirectory(path.join(cwd, GlobalFileNames.clineRules))
|
||||
const rulesFilesTotalContent = await getClineRulesFilesTotalContent(rulesFilePaths, cwd)
|
||||
clineRulesFileInstructions = formatResponse.clineRulesDirectoryInstructions(cwd, rulesFilesTotalContent)
|
||||
} catch {
|
||||
console.error(`Failed to read .clinerules directory at ${clineRulesFilePath}`)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const ruleFileContent = (await fs.readFile(clineRulesFilePath, "utf8")).trim()
|
||||
if (ruleFileContent) {
|
||||
clineRulesFileInstructions = formatResponse.clineRulesFileInstructions(cwd, ruleFileContent)
|
||||
}
|
||||
} catch {
|
||||
console.error(`Failed to read .clinerules file at ${clineRulesFilePath}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return clineRulesFileInstructions
|
||||
}
|
||||
|
||||
const getClineRulesFilesTotalContent = async (rulesFilePaths: string[], cwd: string) => {
|
||||
const ruleFilesTotalContent = await Promise.all(
|
||||
rulesFilePaths.map(async (filePath) => {
|
||||
const ruleFilePath = path.resolve(cwd, filePath)
|
||||
const ruleFilePathRelative = path.relative(cwd, ruleFilePath)
|
||||
return `${ruleFilePathRelative}\n` + (await fs.readFile(ruleFilePath, "utf8")).trim()
|
||||
}),
|
||||
).then((contents) => contents.join("\n\n"))
|
||||
return ruleFilesTotalContent
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import { diagnosticsToProblemsString } from "../../integrations/diagnostics"
|
||||
import { getLatestTerminalOutput } from "../../integrations/terminal/get-latest-output"
|
||||
import { getCommitInfo } from "../../utils/git"
|
||||
import { getWorkingState } from "../../utils/git"
|
||||
import { FileContextTracker } from "../context/context-tracking/FileContextTracker"
|
||||
|
||||
export function openMention(mention?: string): void {
|
||||
if (!mention) {
|
||||
@@ -38,7 +39,12 @@ export function openMention(mention?: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseMentions(text: string, cwd: string, urlContentFetcher: UrlContentFetcher): Promise<string> {
|
||||
export async function parseMentions(
|
||||
text: string,
|
||||
cwd: string,
|
||||
urlContentFetcher: UrlContentFetcher,
|
||||
fileContextTracker?: FileContextTracker,
|
||||
): Promise<string> {
|
||||
const mentions: Set<string> = new Set()
|
||||
let parsedText = text.replace(mentionRegexGlobal, (match, mention) => {
|
||||
mentions.add(mention)
|
||||
@@ -98,6 +104,10 @@ export async function parseMentions(text: string, cwd: string, urlContentFetcher
|
||||
parsedText += `\n\n<folder_content path="${mentionPath}">\n${content}\n</folder_content>`
|
||||
} else {
|
||||
parsedText += `\n\n<file_content path="${mentionPath}">\n${content}\n</file_content>`
|
||||
// Track that this file was mentioned and its content was included
|
||||
if (fileContextTracker) {
|
||||
await fileContextTracker.trackFileContext(mentionPath, "file_mentioned")
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (mention.endsWith("/")) {
|
||||
|
||||
@@ -4,6 +4,12 @@ import * as path from "path"
|
||||
import { ClineIgnoreController, LOCK_TEXT_SYMBOL } from "../ignore/ClineIgnoreController"
|
||||
|
||||
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.]`,
|
||||
|
||||
contextTruncationNotice: () =>
|
||||
`[NOTE] Some previous conversation history with the user has been removed to maintain optimal context window length. The initial user task and the most recent exchanges have been retained for continuity, while intermediate conversation history has been removed. Please keep this in mind as you continue assisting the user.`,
|
||||
|
||||
toolDenied: () => `The user denied this operation.`,
|
||||
|
||||
toolError: (error?: string) => `The tool execution failed with the following error:\n<error>\n${error}\n</error>`,
|
||||
@@ -117,6 +123,92 @@ Otherwise, if you have not completed the task and do not need additional informa
|
||||
const prettyPatchLines = lines.slice(4)
|
||||
return prettyPatchLines.join("\n")
|
||||
},
|
||||
|
||||
taskResumption: (
|
||||
mode: "plan" | "act",
|
||||
agoText: string,
|
||||
cwd: string,
|
||||
wasRecent: boolean | 0 | undefined,
|
||||
responseText?: string,
|
||||
): [string, string] => {
|
||||
const taskResumptionMessage = `[TASK RESUMPTION] ${
|
||||
mode === "plan"
|
||||
? `This task was interrupted ${agoText}. The conversation may have been incomplete. Be aware that the project state may have changed since then. The current working directory is now '${cwd.toPosix()}'.\n\nNote: If you previously attempted a tool use that the user did not provide a result for, you should assume the tool use was not successful. However you are in PLAN MODE, so rather than continuing the task, you must respond to the user's message.`
|
||||
: `This task was interrupted ${agoText}. It may or may not be complete, so please reassess the task context. Be aware that the project state may have changed since then. The current working directory is now '${cwd.toPosix()}'. If the task has not been completed, retry the last step before interruption and proceed with completing the task.\n\nNote: If you previously attempted a tool use that the user did not provide a result for, you should assume the tool use was not successful and assess whether you should retry. If the last tool was a browser_action, the browser has been closed and you must launch a new browser if needed.`
|
||||
}${
|
||||
wasRecent
|
||||
? "\n\nIMPORTANT: If the last tool use was a replace_in_file or write_to_file that was interrupted, the file was reverted back to its original state before the interrupted edit, and you do NOT need to re-read the file as you already have its up-to-date contents."
|
||||
: ""
|
||||
}`
|
||||
|
||||
const userResponseMessage = `${
|
||||
responseText
|
||||
? `${mode === "plan" ? "New message to respond to with plan_mode_respond tool (be sure to provide your response in the <response> parameter)" : "New instructions for task continuation"}:\n<user_message>\n${responseText}\n</user_message>`
|
||||
: mode === "plan"
|
||||
? "(The user did not provide a new message. Consider asking them how they'd like you to proceed, or suggest to them to switch to Act mode to continue with the task.)"
|
||||
: ""
|
||||
}`
|
||||
|
||||
return [taskResumptionMessage, userResponseMessage]
|
||||
},
|
||||
|
||||
planModeInstructions: () => {
|
||||
return `In this mode you should focus on information gathering, asking questions, and architecting a solution. Once you have a plan, use the plan_mode_respond tool to engage in a conversational back and forth with the user. Do not use the plan_mode_respond tool until you've gathered all the information you need e.g. with read_file or ask_followup_question.
|
||||
(Remember: If it seems the user wants you to use tools only available in Act Mode, you should ask the user to "toggle to Act mode" (use those words) - they will have to manually do this themselves with the Plan/Act toggle button below. You do not have the ability to switch to Act Mode yourself, and must wait for the user to do it themselves once they are satisfied with the plan. You also cannot present an option to toggle to Act mode, as this will be something you need to direct the user to do manually themselves.)`
|
||||
},
|
||||
|
||||
fileEditWithUserChanges: (
|
||||
relPath: string,
|
||||
userEdits: string,
|
||||
autoFormattingEdits: string | undefined,
|
||||
finalContent: string | undefined,
|
||||
newProblemsMessage: string | undefined,
|
||||
) =>
|
||||
`The user made the following updates to your content:\n\n${userEdits}\n\n` +
|
||||
(autoFormattingEdits
|
||||
? `The user's editor also applied the following auto-formatting to your content:\n\n${autoFormattingEdits}\n\n(Note: Pay close attention to changes such as single quotes being converted to double quotes, semicolons being removed or added, long lines being broken into multiple lines, adjusting indentation style, adding/removing trailing commas, etc. This will help you ensure future SEARCH/REPLACE operations to this file are accurate.)\n\n`
|
||||
: "") +
|
||||
`The updated content, which includes both your original modifications and the additional edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file that was saved:\n\n` +
|
||||
`<final_file_content path="${relPath.toPosix()}">\n${finalContent}\n</final_file_content>\n\n` +
|
||||
`Please note:\n` +
|
||||
`1. You do not need to re-write the file with these changes, as they have already been applied.\n` +
|
||||
`2. Proceed with the task using this updated file content as the new baseline.\n` +
|
||||
`3. If the user's edits have addressed part of the task or changed the requirements, adjust your approach accordingly.` +
|
||||
`4. IMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference. This content reflects the current state of the file, including both user edits and any auto-formatting (e.g., if you used single quotes but the formatter converted them to double quotes). Always base your SEARCH/REPLACE operations on this final version to ensure accuracy.\n` +
|
||||
`${newProblemsMessage}`,
|
||||
|
||||
fileEditWithoutUserChanges: (
|
||||
relPath: string,
|
||||
autoFormattingEdits: string | undefined,
|
||||
finalContent: string | undefined,
|
||||
newProblemsMessage: string | undefined,
|
||||
) =>
|
||||
`The content was successfully saved to ${relPath.toPosix()}.\n\n` +
|
||||
(autoFormattingEdits
|
||||
? `Along with your edits, the user's editor applied the following auto-formatting to your content:\n\n${autoFormattingEdits}\n\n(Note: Pay close attention to changes such as single quotes being converted to double quotes, semicolons being removed or added, long lines being broken into multiple lines, adjusting indentation style, adding/removing trailing commas, etc. This will help you ensure future SEARCH/REPLACE operations to this file are accurate.)\n\n`
|
||||
: "") +
|
||||
`Here is the full, updated content of the file that was saved:\n\n` +
|
||||
`<final_file_content path="${relPath.toPosix()}">\n${finalContent}\n</final_file_content>\n\n` +
|
||||
`IMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference. This content reflects the current state of the file, including any auto-formatting (e.g., if you used single quotes but the formatter converted them to double quotes). Always base your SEARCH/REPLACE operations on this final version to ensure accuracy.\n\n` +
|
||||
`${newProblemsMessage}`,
|
||||
|
||||
diffError: (relPath: string, originalContent: string | undefined) =>
|
||||
`This is likely because the SEARCH block content doesn't match exactly with what's in the file, or if you used multiple SEARCH/REPLACE blocks they may not have been in the order they appear in the file.\n\n` +
|
||||
`The file was reverted to its original state:\n\n` +
|
||||
`<file_content path="${relPath.toPosix()}">\n${originalContent}\n</file_content>\n\n` +
|
||||
`Now that you have the latest state of the file, try the operation again with fewer more precise SEARCH blocks. It may be prudent, especially for large files, to try to limit yourself to 1-3 SEARCH/REPLACE blocks at a time, then wait for the user to respond with the result of the operation before following up with another replace_in_file call to make additional edits.\n(If you run into this error 3 times in a row, you may use the write_to_file tool as a fallback.)`,
|
||||
|
||||
toolAlreadyUsed: (toolName: string) =>
|
||||
`Tool [${toolName}] was not executed because a tool has already been used in this message. Only one tool may be used per message. You must assess the first tool's result before proceeding to use the next tool.`,
|
||||
|
||||
clineIgnoreInstructions: (content: string) =>
|
||||
`# .clineignore\n\n(The following is provided by a root-level .clineignore file where the user has specified files and directories that should not be accessed. When using list_files, you'll notice a ${LOCK_TEXT_SYMBOL} next to files that are blocked. Attempting to access the file's contents e.g. through read_file will result in an error.)\n\n${content}\n.clineignore`,
|
||||
|
||||
clineRulesDirectoryInstructions: (cwd: string, content: string) =>
|
||||
`# .clinerules/\n\nThe following is provided by a root-level .clinerules/ directory where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${content}`,
|
||||
|
||||
clineRulesFileInstructions: (cwd: string, content: string) =>
|
||||
`# .clinerules\n\nThe following is provided by a root-level .clinerules file where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${content}`,
|
||||
}
|
||||
|
||||
// to avoid circular dependency
|
||||
|
||||
+29
-12
@@ -216,7 +216,7 @@ Usage:
|
||||
Description: Ask the user a question to gather additional information needed to complete the task. This tool should be used when you encounter ambiguities, need clarification, or require more details to proceed effectively. It allows for interactive problem-solving by enabling direct communication with the user. Use this tool judiciously to maintain a balance between gathering necessary information and avoiding excessive back-and-forth.
|
||||
Parameters:
|
||||
- question: (required) The question to ask the user. This should be a clear, specific question that addresses the information you need.
|
||||
- options: (optional) An array of 2-5 options for the user to choose from. Each option should be a string describing a possible answer. You may not always need to provide options, but it may be helpful in many cases where it can save the user from having to type out a response manually.
|
||||
- options: (optional) An array of 2-5 options for the user to choose from. Each option should be a string describing a possible answer. You may not always need to provide options, but it may be helpful in many cases where it can save the user from having to type out a response manually. IMPORTANT: NEVER include an option to toggle to Act mode, as this would be something you need to direct the user to do manually themselves if needed.
|
||||
Usage:
|
||||
<ask_followup_question>
|
||||
<question>Your question here</question>
|
||||
@@ -239,18 +239,35 @@ Your final result description here
|
||||
<command>Command to demonstrate result (optional)</command>
|
||||
</attempt_completion>
|
||||
|
||||
## plan_mode_response
|
||||
## new_task
|
||||
Description: Request to create a new task with preloaded context. The user will be presented with a preview of the context and can choose to create a new task or keep chatting in the current conversation. The user may choose to start a new task at any point.
|
||||
Parameters:
|
||||
- context: (required) The context to preload the new task with. This should include:
|
||||
* Comprehensively explain what has been accomplished in the current task - mention specific file names that are relevant
|
||||
* The specific next steps or focus for the new task - mention specific file names that are relevant
|
||||
* Any critical information needed to continue the work
|
||||
* Clear indication of how this new task relates to the overall workflow
|
||||
* This should be akin to a long handoff file, enough for a totally new developer to be able to pick up where you left off and know exactly what to do next and which files to look at.
|
||||
Usage:
|
||||
<new_task>
|
||||
<context>context to preload new task with</context>
|
||||
</new_task>
|
||||
|
||||
## plan_mode_respond
|
||||
Description: Respond to the user's inquiry in an effort to plan a solution to the user's task. This tool should be used when you need to provide a response to a question or statement from the user about how you plan to accomplish the task. This tool is only available in PLAN MODE. The environment_details will specify the current mode, if it is not PLAN MODE then you should not use this tool. Depending on the user's message, you may ask questions to get clarification about the user's request, architect a solution to the task, and to brainstorm ideas with the user. For example, if the user's task is to create a website, you may start by asking some clarifying questions, then present a detailed plan for how you will accomplish the task given the context, and perhaps engage in a back and forth to finalize the details before the user switches you to ACT MODE to implement the solution.
|
||||
Parameters:
|
||||
- response: (required) The response to provide to the user. Do not try to use tools in this parameter, this is simply a chat response.
|
||||
- options: (optional) An array of 2-5 options for the user to choose from. Each option should be a string describing a possible choice or path forward in the planning process. This can help guide the discussion and make it easier for the user to provide input on key decisions. You may not always need to provide options, but it may be helpful in many cases where it can save the user from having to type out a response manually. Do NOT present an option to toggle to Act mode, as this will be something you need to direct the user to do manually themselves.
|
||||
- response: (required) The response to provide to the user. Do not try to use tools in this parameter, this is simply a chat response. (You MUST use the response parameter, do not simply place the response text directly within <plan_mode_respond> tags.)
|
||||
Usage:
|
||||
<plan_mode_response>
|
||||
<plan_mode_respond>
|
||||
<response>Your response here</response>
|
||||
<options>
|
||||
Array of options here (optional), e.g. ["Option 1", "Option 2", "Option 3"]
|
||||
</options>
|
||||
</plan_mode_response>
|
||||
</plan_mode_respond>
|
||||
|
||||
## load_mcp_documentation
|
||||
Description: Load documentation about creating MCP servers. This tool should be used when the user requests to create or install an MCP server (the user may ask you something along the lines of "add a tool" that does some function, in other words to create an MCP server that provides tools and resources that may connect to external APIs for example. You have the ability to create an MCP server and add it to a configuration file that will then expose the tools and resources for you to use with \`use_mcp_tool\` and \`access_mcp_resource\`). The documentation provides detailed information about the MCP server creation process, including setup instructions, best practices, and examples.
|
||||
Parameters: None
|
||||
Usage:
|
||||
<load_mcp_documentation>
|
||||
</load_mcp_documentation>
|
||||
|
||||
# Tool Use Examples
|
||||
|
||||
@@ -881,11 +898,11 @@ ACT MODE V.S. PLAN MODE
|
||||
|
||||
In each user message, the environment_details will specify the current mode. There are two modes:
|
||||
|
||||
- ACT MODE: In this mode, you have access to all tools EXCEPT the plan_mode_response tool.
|
||||
- ACT MODE: In this mode, you have access to all tools EXCEPT the plan_mode_respond tool.
|
||||
- In ACT MODE, you use tools to accomplish the user's task. Once you've completed the user's task, you use the attempt_completion tool to present the result of the task to the user.
|
||||
- PLAN MODE: In this special mode, you have access to the plan_mode_response tool.
|
||||
- PLAN MODE: In this special mode, you have access to the plan_mode_respond tool.
|
||||
- In PLAN MODE, the goal is to gather information and get context to create a detailed plan for accomplishing the task, which the user will review and approve before they switch you to ACT MODE to implement the solution.
|
||||
- In PLAN MODE, when you need to converse with the user or present a plan, you should use the plan_mode_response tool to deliver your response directly, rather than using <thinking> tags to analyze when to respond. Do not talk about using plan_mode_response - just use it directly to share your thoughts and provide helpful answers.
|
||||
- In PLAN MODE, when you need to converse with the user or present a plan, you should use the plan_mode_respond tool to deliver your response directly, rather than using <thinking> tags to analyze when to respond. Do not talk about using plan_mode_respond - just use it directly to share your thoughts and provide helpful answers.
|
||||
|
||||
## What is PLAN MODE?
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import fs from "fs/promises"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { ClineMessage } from "../../shared/ExtensionMessage"
|
||||
import { TaskMetadata } from "../context/context-tracking/ContextTrackerTypes"
|
||||
export const GlobalFileNames = {
|
||||
apiConversationHistory: "api_conversation_history.json",
|
||||
contextHistory: "context_history.json",
|
||||
uiMessages: "ui_messages.json",
|
||||
openRouterModels: "openrouter_models.json",
|
||||
mcpSettings: "cline_mcp_settings.json",
|
||||
clineRules: ".clinerules",
|
||||
taskMetadata: "task_metadata.json",
|
||||
}
|
||||
|
||||
export async function ensureTaskDirectoryExists(context: vscode.ExtensionContext, taskId: string): Promise<string> {
|
||||
const globalStoragePath = context.globalStorageUri.fsPath
|
||||
const taskDir = path.join(globalStoragePath, "tasks", taskId)
|
||||
await fs.mkdir(taskDir, { recursive: true })
|
||||
return taskDir
|
||||
}
|
||||
|
||||
export async function getSavedApiConversationHistory(
|
||||
context: vscode.ExtensionContext,
|
||||
taskId: string,
|
||||
): Promise<Anthropic.MessageParam[]> {
|
||||
const filePath = path.join(await ensureTaskDirectoryExists(context, taskId), GlobalFileNames.apiConversationHistory)
|
||||
const fileExists = await fileExistsAtPath(filePath)
|
||||
if (fileExists) {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8"))
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export async function saveApiConversationHistory(
|
||||
context: vscode.ExtensionContext,
|
||||
taskId: string,
|
||||
apiConversationHistory: Anthropic.MessageParam[],
|
||||
) {
|
||||
try {
|
||||
const filePath = path.join(await ensureTaskDirectoryExists(context, taskId), GlobalFileNames.apiConversationHistory)
|
||||
await fs.writeFile(filePath, JSON.stringify(apiConversationHistory))
|
||||
} catch (error) {
|
||||
// in the off chance this fails, we don't want to stop the task
|
||||
console.error("Failed to save API conversation history:", error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSavedClineMessages(context: vscode.ExtensionContext, taskId: string): Promise<ClineMessage[]> {
|
||||
const filePath = path.join(await ensureTaskDirectoryExists(context, 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(context, 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 []
|
||||
}
|
||||
|
||||
export async function saveClineMessages(context: vscode.ExtensionContext, taskId: string, uiMessages: ClineMessage[]) {
|
||||
try {
|
||||
const taskDir = await ensureTaskDirectoryExists(context, taskId)
|
||||
const filePath = path.join(taskDir, GlobalFileNames.uiMessages)
|
||||
await fs.writeFile(filePath, JSON.stringify(uiMessages))
|
||||
} catch (error) {
|
||||
console.error("Failed to save ui messages:", error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTaskMetadata(context: vscode.ExtensionContext, taskId: string): Promise<TaskMetadata> {
|
||||
const filePath = path.join(await ensureTaskDirectoryExists(context, taskId), GlobalFileNames.taskMetadata)
|
||||
try {
|
||||
if (await fileExistsAtPath(filePath)) {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8"))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to read task metadata:", error)
|
||||
}
|
||||
return { files_in_context: [], model_usage: [] }
|
||||
}
|
||||
|
||||
export async function saveTaskMetadata(context: vscode.ExtensionContext, taskId: string, metadata: TaskMetadata) {
|
||||
try {
|
||||
const taskDir = await ensureTaskDirectoryExists(context, taskId)
|
||||
const filePath = path.join(taskDir, GlobalFileNames.taskMetadata)
|
||||
await fs.writeFile(filePath, JSON.stringify(metadata, null, 2))
|
||||
} catch (error) {
|
||||
console.error("Failed to save task metadata:", error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
export type SecretKey =
|
||||
| "apiKey"
|
||||
| "clineApiKey"
|
||||
| "openRouterApiKey"
|
||||
| "awsAccessKey"
|
||||
| "awsSecretKey"
|
||||
| "awsSessionToken"
|
||||
| "openAiApiKey"
|
||||
| "geminiApiKey"
|
||||
| "openAiNativeApiKey"
|
||||
| "deepSeekApiKey"
|
||||
| "requestyApiKey"
|
||||
| "togetherApiKey"
|
||||
| "qwenApiKey"
|
||||
| "doubaoApiKey"
|
||||
| "mistralApiKey"
|
||||
| "liteLlmApiKey"
|
||||
| "authNonce"
|
||||
| "asksageApiKey"
|
||||
| "xaiApiKey"
|
||||
| "sambanovaApiKey"
|
||||
export type GlobalStateKey =
|
||||
| "apiProvider"
|
||||
| "apiModelId"
|
||||
| "awsRegion"
|
||||
| "awsUseCrossRegionInference"
|
||||
| "awsBedrockUsePromptCache"
|
||||
| "awsBedrockEndpoint"
|
||||
| "awsProfile"
|
||||
| "awsUseProfile"
|
||||
| "vertexProjectId"
|
||||
| "vertexRegion"
|
||||
| "lastShownAnnouncementId"
|
||||
| "customInstructions"
|
||||
| "taskHistory"
|
||||
| "openAiBaseUrl"
|
||||
| "openAiModelId"
|
||||
| "openAiModelInfo"
|
||||
| "ollamaModelId"
|
||||
| "ollamaBaseUrl"
|
||||
| "ollamaApiOptionsCtxNum"
|
||||
| "lmStudioModelId"
|
||||
| "lmStudioBaseUrl"
|
||||
| "anthropicBaseUrl"
|
||||
| "azureApiVersion"
|
||||
| "openRouterModelId"
|
||||
| "openRouterModelInfo"
|
||||
| "openRouterProviderSorting"
|
||||
| "autoApprovalSettings"
|
||||
| "browserSettings"
|
||||
| "chatSettings"
|
||||
| "vsCodeLmModelSelector"
|
||||
| "userInfo"
|
||||
| "previousModeApiProvider"
|
||||
| "previousModeModelId"
|
||||
| "previousModeThinkingBudgetTokens"
|
||||
| "previousModeReasoningEffort"
|
||||
| "previousModeVsCodeLmModelSelector"
|
||||
| "previousModeModelInfo"
|
||||
| "liteLlmBaseUrl"
|
||||
| "liteLlmModelId"
|
||||
| "liteLlmUsePromptCache"
|
||||
| "qwenApiLine"
|
||||
| "requestyModelId"
|
||||
| "requestyModelInfo"
|
||||
| "togetherModelId"
|
||||
| "mcpMarketplaceCatalog"
|
||||
| "telemetrySetting"
|
||||
| "asksageApiUrl"
|
||||
| "thinkingBudgetTokens"
|
||||
| "reasoningEffort"
|
||||
| "planActSeparateModelsSetting"
|
||||
| "favoritedModelIds"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user