diff --git a/.roo/rules-issue-writer/1_workflow.xml b/.roo/rules-issue-writer/1_workflow.xml
index 9c71a589ad..3aca1599ef 100644
--- a/.roo/rules-issue-writer/1_workflow.xml
+++ b/.roo/rules-issue-writer/1_workflow.xml
@@ -30,10 +30,11 @@
- Any error messages or logs
For Feature Requests, ensure you have:
- - Specific problem description with impact
- - Detailed proposed solution
- - Clear acceptance criteria in Given/When/Then format
- - Effort estimation with reasoning
+ - Specific problem description with impact (who is affected, when it happens, current vs expected behavior, impact)
+ - Additional context if available (mockups, screenshots, links)
+
+ IMPORTANT: Do NOT ask for solution design, acceptance criteria, or technical details
+ unless the user explicitly states they want to contribute the implementation.
Use multiple ask_followup_question calls if needed to gather all information.
Be specific in your questions based on what's missing.
@@ -63,8 +64,31 @@
- Explore Codebase for Context
+ Determine if User Wants to Contribute
+
+ Before exploring the codebase, determine if the user wants to contribute the implementation:
+
+
+ Are you interested in implementing this feature yourself, or are you just reporting the problem for the Roo team to solve?
+
+ Just reporting the problem - the Roo team can design the solution
+ I want to contribute and implement this feature myself
+ I'm not sure yet, but I'd like to provide technical analysis
+
+
+
+ Based on their response:
+ - If just reporting: Skip to step 6 (Draft Issue - Problem Only)
+ - If contributing: Continue to step 5 (Explore Codebase)
+ - If providing analysis: Continue to step 5 but make technical sections optional
+
+
+
+
+ Explore Codebase for Contributors
+ ONLY perform this step if the user wants to contribute or provide technical analysis.
+
Use codebase_search FIRST to understand the relevant parts of the codebase:
For Bug Reports:
@@ -95,15 +119,20 @@
- Current implementation details
- Your proposed implementation plan
- Related code that might be affected
+
+ Then gather additional technical details:
+ - Ask for proposed solution approach
+ - Request acceptance criteria in Given/When/Then format
+ - Discuss technical considerations and trade-offs
-
- Draft Complete Issue Content
+
+ Draft Issue Content
- Create the complete issue body following the exact template structure.
+ Create the issue body based on whether the user is just reporting or contributing.
- For Bug Reports, format as:
+ For Bug Reports, format is the same regardless of contribution intent:
```
## App Version
[version from user]
@@ -146,7 +175,30 @@
- **Proposed Fix:** [Detail the fix from your implementation plan.]
```
- For Feature Requests, format as:
+ For Feature Requests - PROBLEM REPORTERS (not contributing):
+ ```
+ ## What specific problem does this solve?
+
+ [Detailed problem description following the template guidelines]
+
+ **Who is affected:** [user groups]
+ **When this happens:** [specific scenarios]
+ **Current behavior:** [what happens now]
+ **Expected behavior:** [what should happen]
+ **Impact:** [time wasted, errors, productivity loss]
+
+ ## Additional context
+
+ [Any mockups, screenshots, links, or other supporting information]
+
+ ## Related Discussions
+
+ [If any related discussions were found, list them here]
+ - Closes #[discussion number] - [discussion title]
+ - Related to #[discussion number] - [discussion title]
+ ```
+
+ For Feature Requests - CONTRIBUTORS (implementing the feature):
```
## What specific problem does this solve?
@@ -158,6 +210,17 @@
**Expected behavior:** [what should happen]
**Impact:** [time wasted, errors, productivity loss]
+ ## Additional context
+
+ [Any mockups, screenshots, links, or other supporting information]
+
+ ---
+
+ ## 🛠️ Contributing & Technical Analysis
+
+ ✅ **I'm interested in implementing this feature**
+ ✅ **I understand this needs approval before implementation begins**
+
## How should this be solved?
[Based on your independent analysis, describe your proposed solution here. Disregard the author's proposal.]
@@ -182,7 +245,7 @@
[Add multiple scenarios as needed]
- ## Estimated Effort and Complexity
+ ## Technical Considerations
**Size:** [estimate]
**Reasoning:** [why this size]
@@ -198,13 +261,24 @@
- Similar patterns in codebase: [examples]
- Implementation Steps: [Provide a detailed, step-by-step guide for your proposed solution.]
- ## Technical Considerations
+ **Performance implications:**
+ [Any performance considerations]
- [Any additional technical details]
+ **Compatibility concerns:**
+ [Any compatibility issues]
## Trade-offs and Risks
- [Alternatives considered and potential issues]
+ **Alternatives considered:**
+ - [Alternative 1]: [Why not chosen]
+ - [Alternative 2]: [Why not chosen]
+
+ **Potential risks:**
+ - [Risk 1]: [Mitigation strategy]
+ - [Risk 2]: [Mitigation strategy]
+
+ **Breaking changes:**
+ [Any breaking changes or migration needs]
## Related Discussions
@@ -215,7 +289,7 @@
-
+
Review and Confirm with User
Present the complete drafted issue to the user for review:
@@ -238,7 +312,7 @@
-
+
Create GitHub Issue
Once user confirms, create the issue using the GitHub MCP tool:
@@ -251,7 +325,7 @@
"owner": "RooCodeInc",
"repo": "Roo-Code",
"title": "[Create a descriptive title based on the issue content]",
- "body": "[The complete formatted issue body from step 4]",
+ "body": "[The complete formatted issue body from step 6]",
"labels": [Use ["bug"] for bug reports or ["proposal", "enhancement"] for features]
}
diff --git a/.roo/rules-issue-writer/2_github_issue_templates.xml b/.roo/rules-issue-writer/2_github_issue_templates.xml
index 006c513808..3130f2026e 100644
--- a/.roo/rules-issue-writer/2_github_issue_templates.xml
+++ b/.roo/rules-issue-writer/2_github_issue_templates.xml
@@ -69,9 +69,9 @@
Detailed Feature Proposal
- Propose a specific, actionable feature or enhancement for implementation
+ Report a specific problem that needs solving in Roo Code
["proposal", "enhancement"]
-
+
@@ -95,9 +95,30 @@
Be specific about the problem, who it affects, and the impact. Avoid generic statements like "it's slow" or "it's confusing."
-
-
+
+
+ Mockups, screenshots, links, user quotes, or other relevant information that supports your proposal.
+
+
+
+
+
+
+
+ **Important:** If you check "Yes" below, the technical sections become REQUIRED.
+ We need detailed technical analysis from contributors to ensure quality implementation.
+
+
+
+
+
+
+
+
+
+ **If you want to implement this feature, this section is REQUIRED.**
+
**Describe your solution in detail.** Explain not just what to build, but how it should work.
✅ **Good examples:**
@@ -117,9 +138,11 @@
Describe the specific changes and how they will work. Include user interaction details if relevant.
-
-
+
+
+ **If you want to implement this feature, this section is REQUIRED.**
+
**This is crucial - don't skip it.** Define what "working" looks like with specific, testable criteria.
**Format suggestion:**
@@ -146,35 +169,11 @@
Use the Given/When/Then format above or your own clear structure.
-
-
+
+
- **Help us understand the scope.** This helps with planning and prioritisation.
+ **If you want to implement this feature, this section is REQUIRED.**
- **Please include:**
- - **Size estimate:** XS/Small/Medium/Large/XL
- - **Reasoning:** What makes it this size? Which parts are complex?
- - **Main challenges:** What's the trickiest bit to implement?
- - **Dependencies:** Does this require other changes or external libraries?
-
- **Example:**
- ```
- Size: Large
- Reasoning: Touches task execution engine, UI components, and state management
- Main challenges: Preventing memory leaks with parallel execution and managing shared resources
- Dependencies: Might need to add a new dependency for the new feature
- ```
-
-
- Size: [your estimate]
- Reasoning: [why this size?]
- Main challenges: [what's tricky?]
- Dependencies: [what else is needed?]
-
-
-
-
-
Share technical insights that could help planning:
- Implementation approach or architecture changes
- Performance implications
@@ -184,9 +183,11 @@
e.g., "Will need to refactor task manager", "Could impact memory usage on large files", "Requires a large portion of code to be rewritten"
-
-
+
+
+ **If you want to implement this feature, this section is REQUIRED.**
+
What could go wrong or what alternatives did you consider?
- Alternative approaches and why you chose this one
- Potential negative impacts (performance, UX, etc.)
@@ -195,10 +196,24 @@
e.g., "Alternative: use library X but it is 500KB larger", "Risk: might slow older devices", "Breaking: changes API response format"
-
-
- Mockups, screenshots, links, user quotes, or other relevant information that supports your proposal.
-
-
+
+
+
+
+ Template now focuses on problem reporting first, with solution contribution as optional
+
+
+ Only problem description and context are required for basic submission
+
+
+ Technical fields (solution, acceptance criteria, etc.) are only required if user wants to contribute
+
+
+ Users can submit after describing the problem without technical details
+
+
+ Implementation guidance moved to contributor section only
+
+
\ No newline at end of file
diff --git a/.roo/rules-issue-writer/3_best_practices.xml b/.roo/rules-issue-writer/3_best_practices.xml
index fc2332dac9..6d70cba144 100644
--- a/.roo/rules-issue-writer/3_best_practices.xml
+++ b/.roo/rules-issue-writer/3_best_practices.xml
@@ -1,14 +1,38 @@
- - Always search for existing similar issues before creating a new one
- - Search GitHub Discussions (especially feature-requests category) for related topics
- - Include specific version numbers and environment details
- - Use code blocks with syntax highlighting for code snippets
- - Reference specific files and line numbers from codebase exploration
- - Make titles descriptive but concise (e.g., "Dark theme: Submit button invisible due to white-on-grey text")
- - For bugs, always test if the issue is reproducible
- - For features, ensure the proposal aligns with project goals
- - Include screenshots or mockups when relevant (ask user to provide)
- - Link to related issues or PRs if found during exploration
- - Add "Closes #[number]" for discussions that would be fully addressed by the issue
- - Add "Related to #[number]" for partially related discussions
+
+ - Focus on helping users describe problems clearly, not solutions
+ - The Roo team will design solutions unless the user explicitly wants to contribute
+ - Don't push users to provide technical details they may not have
+ - Make it easy for non-technical users to report issues effectively
+
+
+
+ - Always search for existing similar issues before creating a new one
+ - Search GitHub Discussions (especially feature-requests category) for related topics
+ - Include specific version numbers and environment details
+ - Use code blocks with syntax highlighting for code snippets
+ - Make titles descriptive but concise (e.g., "Dark theme: Submit button invisible due to white-on-grey text")
+ - For bugs, always test if the issue is reproducible
+ - Include screenshots or mockups when relevant (ask user to provide)
+ - Link to related issues or PRs if found during exploration
+ - Add "Closes #[number]" for discussions that would be fully addressed by the issue
+ - Add "Related to #[number]" for partially related discussions
+
+
+
+ - Only explore codebase if user wants to contribute
+ - Reference specific files and line numbers from codebase exploration
+ - Ensure technical proposals align with project architecture
+ - Include implementation steps and technical analysis
+ - Provide clear acceptance criteria in Given/When/Then format
+ - Consider trade-offs and alternative approaches
+
+
+
+ - Be supportive and encouraging to problem reporters
+ - Don't overwhelm users with technical questions upfront
+ - Clearly indicate when technical sections are optional
+ - Guide contributors through the additional requirements
+ - Make the "submit now" option clear for problem reporters
+
\ No newline at end of file
diff --git a/.roo/rules-issue-writer/4_common_mistakes_to_avoid.xml b/.roo/rules-issue-writer/4_common_mistakes_to_avoid.xml
index 7818d5e29a..2013bd73d8 100644
--- a/.roo/rules-issue-writer/4_common_mistakes_to_avoid.xml
+++ b/.roo/rules-issue-writer/4_common_mistakes_to_avoid.xml
@@ -1,10 +1,30 @@
- - Vague descriptions like "doesn't work" or "broken"
- - Missing reproduction steps for bugs
- - Feature requests without clear problem statements
- - No acceptance criteria for features
- - Forgetting to include technical context from code exploration
- - Not checking for duplicates
- - Using wrong labels or no labels
- - Titles that don't summarize the issue
+
+ - Vague descriptions like "doesn't work" or "broken"
+ - Missing reproduction steps for bugs
+ - Feature requests without clear problem statements
+ - Not explaining the impact on users
+ - Forgetting to specify when/how the problem occurs
+ - Using wrong labels or no labels
+ - Titles that don't summarize the issue
+ - Not checking for duplicates
+
+
+
+ - Asking for technical details from non-contributing users
+ - Exploring codebase before confirming user wants to contribute
+ - Requiring acceptance criteria from problem reporters
+ - Making the process too complex for simple problem reports
+ - Not clearly indicating the "submit now" option
+ - Overwhelming users with contributor requirements upfront
+
+
+
+ - Starting implementation before approval
+ - Not providing detailed technical analysis when contributing
+ - Missing acceptance criteria for contributed features
+ - Forgetting to include technical context from code exploration
+ - Not considering trade-offs and alternatives
+ - Proposing solutions without understanding current architecture
+
\ No newline at end of file
diff --git a/.roo/rules-issue-writer/5_github_mcp_tool_usage.xml b/.roo/rules-issue-writer/5_github_mcp_tool_usage.xml
index b99229acff..cbab5da105 100644
--- a/.roo/rules-issue-writer/5_github_mcp_tool_usage.xml
+++ b/.roo/rules-issue-writer/5_github_mcp_tool_usage.xml
@@ -4,7 +4,8 @@
Here's when and how to use each tool in the issue creation workflow.
Note: Issue body formatting should follow the templates defined in
- and
+ 2_github_issue_templates.xml, with different formats for problem reporters
+ vs contributors.
@@ -93,10 +94,15 @@
-
+
+
+ These tools should ONLY be used if the user has indicated they want to
+ contribute the implementation. Skip these for problem reporters.
+
+
- For bug reports, check recent commits that might have introduced the issue.
+ For bug reports from contributors, check recent commits that might have introduced the issue.
Look for commits touching the affected files.
@@ -137,7 +143,7 @@
Use to find code patterns across the repository on GitHub.
- Complements local codebase_search tool.
+ Complements local codebase_search tool for contributors.
@@ -174,15 +180,15 @@
-
+
Only use after:
1. Confirming no duplicates exist
- 2. Gathering all required information per or
- 3. Exploring codebase for context
+ 2. Gathering all required information
+ 3. Determining if user is contributing or just reporting
4. Getting user confirmation
@@ -194,13 +200,13 @@
"owner": "RooCodeInc",
"repo": "Roo-Code",
"title": "[Descriptive title of the bug]",
- "body": "[Format according to fields]",
+ "body": "[Format according to bug report template]",
"labels": ["bug"]
}
-
+
github
create_issue
@@ -208,13 +214,28 @@
{
"owner": "RooCodeInc",
"repo": "Roo-Code",
- "title": "[Descriptive title of the feature request]",
- "body": "[Format according to fields]",
+ "title": "[Problem-focused title]",
+ "body": "[Problem description only - no technical details]",
"labels": ["proposal", "enhancement"]
}
-
+
+
+
+ github
+ create_issue
+
+ {
+ "owner": "RooCodeInc",
+ "repo": "Roo-Code",
+ "title": "[Problem-focused title with implementation intent]",
+ "body": "[Full template including technical analysis sections]",
+ "labels": ["proposal", "enhancement"]
+ }
+
+
+
@@ -281,21 +302,30 @@
- During codebase exploration:
+ Decision point for contribution:
+ 1. Ask user if they want to contribute implementation
+ 2. If yes: Use contributor tools for codebase investigation
+ 3. If no: Skip directly to creating a problem-focused issue
+ 4. This saves time for problem reporters
+
+
+
+ During codebase exploration (CONTRIBUTORS ONLY):
1. Use list_commits to find recent changes to affected files
2. Use search_code for additional code references
3. Check list_pull_requests for related PRs
4. Include findings in the technical context section
-
+
-
+
When creating the issue:
- 1. Format the issue body according to or
- 2. Use create_issue with complete formatted body
- 3. Capture the returned issue number
- 4. If related issues were found, use add_issue_comment to link them
- 5. Show user the created issue URL
-
+ 1. Format differently based on contributor vs problem reporter
+ 2. Problem reporters: Simple problem description + context
+ 3. Contributors: Full template with technical sections
+ 4. Use create_issue with appropriate body format
+ 5. Capture the returned issue number
+ 6. Show user the created issue URL
+
diff --git a/packages/cloud/src/AuthService.ts b/packages/cloud/src/AuthService.ts
index 68036ce3c9..d0bbb63cc0 100644
--- a/packages/cloud/src/AuthService.ts
+++ b/packages/cloud/src/AuthService.ts
@@ -1,7 +1,6 @@
import crypto from "crypto"
import EventEmitter from "events"
-import axios from "axios"
import * as vscode from "vscode"
import { z } from "zod"
@@ -30,6 +29,61 @@ const AUTH_STATE_KEY = "clerk-auth-state"
type AuthState = "initializing" | "logged-out" | "active-session" | "inactive-session"
+const clerkSignInResponseSchema = z.object({
+ response: z.object({
+ created_session_id: z.string(),
+ }),
+})
+
+const clerkCreateSessionTokenResponseSchema = z.object({
+ jwt: z.string(),
+})
+
+const clerkMeResponseSchema = z.object({
+ response: z.object({
+ first_name: z.string().optional(),
+ last_name: z.string().optional(),
+ image_url: z.string().optional(),
+ primary_email_address_id: z.string().optional(),
+ email_addresses: z
+ .array(
+ z.object({
+ id: z.string(),
+ email_address: z.string(),
+ }),
+ )
+ .optional(),
+ }),
+})
+
+const clerkOrganizationMembershipsSchema = z.object({
+ response: z.array(
+ z.object({
+ id: z.string(),
+ role: z.string(),
+ permissions: z.array(z.string()).optional(),
+ created_at: z.number().optional(),
+ updated_at: z.number().optional(),
+ organization: z.object({
+ id: z.string(),
+ name: z.string(),
+ slug: z.string().optional(),
+ image_url: z.string().optional(),
+ has_image: z.boolean().optional(),
+ created_at: z.number().optional(),
+ updated_at: z.number().optional(),
+ }),
+ }),
+ ),
+})
+
+class InvalidClientTokenError extends Error {
+ constructor() {
+ super("Invalid/Expired client token")
+ Object.setPrototypeOf(this, InvalidClientTokenError.prototype)
+ }
+}
+
export class AuthService extends EventEmitter {
private context: vscode.ExtensionContext
private timer: RefreshTimer
@@ -208,7 +262,7 @@ export class AuthService extends EventEmitter {
throw new Error("Invalid state parameter. Authentication request may have been tampered with.")
}
- const { credentials } = await this.clerkSignIn(code)
+ const credentials = await this.clerkSignIn(code)
await this.storeCredentials(credentials)
@@ -285,7 +339,6 @@ export class AuthService extends EventEmitter {
private async refreshSession(): Promise {
if (!this.credentials) {
this.log("[auth] Cannot refresh session: missing credentials")
- this.state = "inactive-session"
return
}
@@ -300,6 +353,10 @@ export class AuthService extends EventEmitter {
this.fetchUserInfo()
}
} catch (error) {
+ if (error instanceof InvalidClientTokenError) {
+ this.log("[auth] Invalid/Expired client token: clearing credentials")
+ this.clearCredentials()
+ }
this.log("[auth] Failed to refresh session", error)
throw error
}
@@ -323,99 +380,89 @@ export class AuthService extends EventEmitter {
return this.userInfo
}
- private async clerkSignIn(ticket: string): Promise<{ credentials: AuthCredentials; sessionToken: string }> {
+ private async clerkSignIn(ticket: string): Promise {
const formData = new URLSearchParams()
formData.append("strategy", "ticket")
formData.append("ticket", ticket)
- const response = await axios.post(`${getClerkBaseUrl()}/v1/client/sign_ins`, formData, {
+ const response = await fetch(`${getClerkBaseUrl()}/v1/client/sign_ins`, {
+ method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": this.userAgent(),
},
+ body: formData.toString(),
+ signal: AbortSignal.timeout(10000),
})
- // 3. Extract the client token from the Authorization header.
- const clientToken = response.headers.authorization
-
- if (!clientToken) {
- throw new Error("No authorization header found in the response")
- }
-
- // 4. Find the session using created_session_id and extract the JWT.
- const sessionId = response.data?.response?.created_session_id
-
- if (!sessionId) {
- throw new Error("No session ID found in the response")
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
- // Find the session in the client sessions array.
- const session = response.data?.client?.sessions?.find((s: { id: string }) => s.id === sessionId)
-
- if (!session) {
- throw new Error("Session not found in the response")
- }
+ const {
+ response: { created_session_id: sessionId },
+ } = clerkSignInResponseSchema.parse(await response.json())
- // Extract the session token (JWT) and store it.
- const sessionToken = session.last_active_token?.jwt
+ // 3. Extract the client token from the Authorization header.
+ const clientToken = response.headers.get("authorization")
- if (!sessionToken) {
- throw new Error("Session does not have a token")
+ if (!clientToken) {
+ throw new Error("No authorization header found in the response")
}
- const credentials = authCredentialsSchema.parse({ clientToken, sessionId })
-
- return { credentials, sessionToken }
+ return authCredentialsSchema.parse({ clientToken, sessionId })
}
private async clerkCreateSessionToken(): Promise {
const formData = new URLSearchParams()
formData.append("_is_native", "1")
- const response = await axios.post(
- `${getClerkBaseUrl()}/v1/client/sessions/${this.credentials!.sessionId}/tokens`,
- formData,
- {
- headers: {
- "Content-Type": "application/x-www-form-urlencoded",
- Authorization: `Bearer ${this.credentials!.clientToken}`,
- "User-Agent": this.userAgent(),
- },
+ const response = await fetch(`${getClerkBaseUrl()}/v1/client/sessions/${this.credentials!.sessionId}/tokens`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ Authorization: `Bearer ${this.credentials!.clientToken}`,
+ "User-Agent": this.userAgent(),
},
- )
-
- const sessionToken = response.data?.jwt
+ body: formData.toString(),
+ signal: AbortSignal.timeout(10000),
+ })
- if (!sessionToken) {
- throw new Error("No JWT found in refresh response")
+ if (response.status >= 400 && response.status < 500) {
+ throw new InvalidClientTokenError()
+ } else if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
- return sessionToken
+ const data = clerkCreateSessionTokenResponseSchema.parse(await response.json())
+
+ return data.jwt
}
private async clerkMe(): Promise {
- const response = await axios.get(`${getClerkBaseUrl()}/v1/me`, {
+ const response = await fetch(`${getClerkBaseUrl()}/v1/me`, {
headers: {
Authorization: `Bearer ${this.credentials!.clientToken}`,
"User-Agent": this.userAgent(),
},
+ signal: AbortSignal.timeout(10000),
})
- const userData = response.data?.response
-
- if (!userData) {
- throw new Error("No response user data")
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
+ const { response: userData } = clerkMeResponseSchema.parse(await response.json())
+
const userInfo: CloudUserInfo = {}
- userInfo.name = `${userData?.first_name} ${userData?.last_name}`
- const primaryEmailAddressId = userData?.primary_email_address_id
- const emailAddresses = userData?.email_addresses
+ userInfo.name = `${userData.first_name} ${userData.last_name}`
+ const primaryEmailAddressId = userData.primary_email_address_id
+ const emailAddresses = userData.email_addresses
if (primaryEmailAddressId && emailAddresses) {
userInfo.email = emailAddresses.find(
- (email: { id: string }) => primaryEmailAddressId === email?.id,
+ (email: { id: string }) => primaryEmailAddressId === email.id,
)?.email_address
}
@@ -460,35 +507,23 @@ export class AuthService extends EventEmitter {
const formData = new URLSearchParams()
formData.append("_is_native", "1")
- await axios.post(`${getClerkBaseUrl()}/v1/client/sessions/${credentials.sessionId}/remove`, formData, {
+ const response = await fetch(`${getClerkBaseUrl()}/v1/client/sessions/${credentials.sessionId}/remove`, {
+ method: "POST",
headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
Authorization: `Bearer ${credentials.clientToken}`,
"User-Agent": this.userAgent(),
},
+ body: formData.toString(),
+ signal: AbortSignal.timeout(10000),
})
- }
-
- private userAgent(): string {
- return getUserAgent(this.context)
- }
- private static _instance: AuthService | null = null
-
- static get instance() {
- if (!this._instance) {
- throw new Error("AuthService not initialized")
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
-
- return this._instance
}
- static async createInstance(context: vscode.ExtensionContext, log?: (...args: unknown[]) => void) {
- if (this._instance) {
- throw new Error("AuthService instance already created")
- }
-
- this._instance = new AuthService(context, log)
- await this._instance.initialize()
- return this._instance
+ private userAgent(): string {
+ return getUserAgent(this.context)
}
}
diff --git a/packages/cloud/src/CloudService.ts b/packages/cloud/src/CloudService.ts
index fe3bad970c..e6f64223e7 100644
--- a/packages/cloud/src/CloudService.ts
+++ b/packages/cloud/src/CloudService.ts
@@ -37,16 +37,18 @@ export class CloudService {
}
try {
- this.authService = await AuthService.createInstance(this.context, this.log)
+ this.authService = new AuthService(this.context, this.log)
+ await this.authService.initialize()
this.authService.on("inactive-session", this.authListener)
this.authService.on("active-session", this.authListener)
this.authService.on("logged-out", this.authListener)
this.authService.on("user-info", this.authListener)
- this.settingsService = await SettingsService.createInstance(this.context, () =>
+ this.settingsService = new SettingsService(this.context, this.authService, () =>
this.callbacks.stateChanged?.(),
)
+ this.settingsService.initialize()
this.telemetryClient = new TelemetryClient(this.authService, this.settingsService)
@@ -162,13 +164,7 @@ export class CloudService {
}
private ensureInitialized(): void {
- if (
- !this.isInitialized ||
- !this.authService ||
- !this.settingsService ||
- !this.telemetryClient ||
- !this.shareService
- ) {
+ if (!this.isInitialized) {
throw new Error("CloudService not initialized.")
}
}
diff --git a/packages/cloud/src/SettingsService.ts b/packages/cloud/src/SettingsService.ts
index d849cc03ae..f9c7246dd5 100644
--- a/packages/cloud/src/SettingsService.ts
+++ b/packages/cloud/src/SettingsService.ts
@@ -14,14 +14,13 @@ import { RefreshTimer } from "./RefreshTimer"
const ORGANIZATION_SETTINGS_CACHE_KEY = "organization-settings"
export class SettingsService {
- private static _instance: SettingsService | null = null
private context: vscode.ExtensionContext
private authService: AuthService
private settings: OrganizationSettings | undefined = undefined
private timer: RefreshTimer
- private constructor(context: vscode.ExtensionContext, authService: AuthService, callback: () => void) {
+ constructor(context: vscode.ExtensionContext, authService: AuthService, callback: () => void) {
this.context = context
this.authService = authService
@@ -122,21 +121,4 @@ export class SettingsService {
this.timer.stop()
}
- static get instance() {
- if (!this._instance) {
- throw new Error("SettingsService not initialized")
- }
-
- return this._instance
- }
-
- static async createInstance(context: vscode.ExtensionContext, callback: () => void) {
- if (this._instance) {
- throw new Error("SettingsService instance already created")
- }
-
- this._instance = new SettingsService(context, AuthService.instance, callback)
- this._instance.initialize()
- return this._instance
- }
}
diff --git a/packages/cloud/src/__mocks__/vscode.ts b/packages/cloud/src/__mocks__/vscode.ts
index c4261941c4..ac9082375e 100644
--- a/packages/cloud/src/__mocks__/vscode.ts
+++ b/packages/cloud/src/__mocks__/vscode.ts
@@ -18,14 +18,18 @@ export interface ExtensionContext {
get: (key: string) => Promise
store: (key: string, value: string) => Promise
delete: (key: string) => Promise
+ onDidChange: (listener: (e: { key: string }) => void) => { dispose: () => void }
}
globalState: {
get: (key: string) => T | undefined
update: (key: string, value: any) => Promise
}
+ subscriptions: any[]
extension?: {
packageJSON?: {
version?: string
+ publisher?: string
+ name?: string
}
}
}
@@ -36,14 +40,18 @@ export const mockExtensionContext: ExtensionContext = {
get: vi.fn().mockResolvedValue(undefined),
store: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(undefined),
+ onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }),
},
globalState: {
get: vi.fn().mockReturnValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
},
+ subscriptions: [],
extension: {
packageJSON: {
version: "1.0.0",
+ publisher: "RooVeterinaryInc",
+ name: "roo-cline",
},
},
}
diff --git a/packages/cloud/src/__tests__/AuthService.spec.ts b/packages/cloud/src/__tests__/AuthService.spec.ts
new file mode 100644
index 0000000000..96c35d8c96
--- /dev/null
+++ b/packages/cloud/src/__tests__/AuthService.spec.ts
@@ -0,0 +1,815 @@
+// npx vitest run src/__tests__/AuthService.spec.ts
+
+import { vi, Mock, beforeEach, afterEach, describe, it, expect } from "vitest"
+import crypto from "crypto"
+import * as vscode from "vscode"
+
+import { AuthService } from "../AuthService"
+import { RefreshTimer } from "../RefreshTimer"
+import * as Config from "../Config"
+import * as utils from "../utils"
+
+// Mock external dependencies
+vi.mock("../RefreshTimer")
+vi.mock("../Config")
+vi.mock("../utils")
+vi.mock("crypto")
+
+// Mock fetch globally
+const mockFetch = vi.fn()
+global.fetch = mockFetch
+
+// Mock vscode module
+vi.mock("vscode", () => ({
+ window: {
+ showInformationMessage: vi.fn(),
+ showErrorMessage: vi.fn(),
+ },
+ env: {
+ openExternal: vi.fn(),
+ uriScheme: "vscode",
+ },
+ Uri: {
+ parse: vi.fn((uri: string) => ({ toString: () => uri })),
+ },
+}))
+
+describe("AuthService", () => {
+ let authService: AuthService
+ let mockTimer: {
+ start: Mock
+ stop: Mock
+ reset: Mock
+ }
+ let mockLog: Mock
+ let mockContext: {
+ subscriptions: { push: Mock }
+ secrets: {
+ get: Mock
+ store: Mock
+ delete: Mock
+ onDidChange: Mock
+ }
+ globalState: {
+ get: Mock
+ update: Mock
+ }
+ extension: {
+ packageJSON: {
+ version: string
+ publisher: string
+ name: string
+ }
+ }
+ }
+
+ beforeEach(() => {
+ // Reset all mocks
+ vi.clearAllMocks()
+
+ // Setup mock context with proper subscriptions array
+ mockContext = {
+ subscriptions: {
+ push: vi.fn(),
+ },
+ secrets: {
+ get: vi.fn().mockResolvedValue(undefined),
+ store: vi.fn().mockResolvedValue(undefined),
+ delete: vi.fn().mockResolvedValue(undefined),
+ onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }),
+ },
+ globalState: {
+ get: vi.fn().mockReturnValue(undefined),
+ update: vi.fn().mockResolvedValue(undefined),
+ },
+ extension: {
+ packageJSON: {
+ version: "1.0.0",
+ publisher: "RooVeterinaryInc",
+ name: "roo-cline",
+ },
+ },
+ }
+
+ // Setup timer mock
+ mockTimer = {
+ start: vi.fn(),
+ stop: vi.fn(),
+ reset: vi.fn(),
+ }
+ vi.mocked(RefreshTimer).mockImplementation(() => mockTimer as unknown as RefreshTimer)
+
+ // Setup config mocks
+ vi.mocked(Config.getClerkBaseUrl).mockReturnValue("https://clerk.test.com")
+ vi.mocked(Config.getRooCodeApiUrl).mockReturnValue("https://api.test.com")
+
+ // Setup utils mock
+ vi.mocked(utils.getUserAgent).mockReturnValue("Roo-Code 1.0.0")
+
+ // Setup crypto mock
+ vi.mocked(crypto.randomBytes).mockReturnValue(Buffer.from("test-random-bytes") as never)
+
+ // Setup log mock
+ mockLog = vi.fn()
+
+ authService = new AuthService(mockContext as unknown as vscode.ExtensionContext, mockLog)
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe("constructor", () => {
+ it("should initialize with correct default values", () => {
+ expect(authService.getState()).toBe("initializing")
+ expect(authService.isAuthenticated()).toBe(false)
+ expect(authService.hasActiveSession()).toBe(false)
+ expect(authService.getSessionToken()).toBeUndefined()
+ expect(authService.getUserInfo()).toBeNull()
+ })
+
+ it("should create RefreshTimer with correct configuration", () => {
+ expect(RefreshTimer).toHaveBeenCalledWith({
+ callback: expect.any(Function),
+ successInterval: 50_000,
+ initialBackoffMs: 1_000,
+ maxBackoffMs: 300_000,
+ })
+ })
+
+ it("should use console.log as default logger", () => {
+ const serviceWithoutLog = new AuthService(mockContext as unknown as vscode.ExtensionContext)
+ // Can't directly test console.log usage, but constructor should not throw
+ expect(serviceWithoutLog).toBeInstanceOf(AuthService)
+ })
+ })
+
+ describe("initialize", () => {
+ it("should handle credentials change and setup event listener", async () => {
+ await authService.initialize()
+
+ expect(mockContext.subscriptions.push).toHaveBeenCalled()
+ expect(mockContext.secrets.onDidChange).toHaveBeenCalled()
+ })
+
+ it("should not initialize twice", async () => {
+ await authService.initialize()
+ const firstCallCount = vi.mocked(mockContext.secrets.onDidChange).mock.calls.length
+
+ await authService.initialize()
+ expect(mockContext.secrets.onDidChange).toHaveBeenCalledTimes(firstCallCount)
+ expect(mockLog).toHaveBeenCalledWith("[auth] initialize() called after already initialized")
+ })
+
+ it("should transition to logged-out when no credentials exist", async () => {
+ mockContext.secrets.get.mockResolvedValue(undefined)
+
+ const loggedOutSpy = vi.fn()
+ authService.on("logged-out", loggedOutSpy)
+
+ await authService.initialize()
+
+ expect(authService.getState()).toBe("logged-out")
+ expect(loggedOutSpy).toHaveBeenCalledWith({ previousState: "initializing" })
+ })
+
+ it("should transition to inactive-session when valid credentials exist", async () => {
+ const credentials = { clientToken: "test-token", sessionId: "test-session" }
+ mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
+
+ const inactiveSessionSpy = vi.fn()
+ authService.on("inactive-session", inactiveSessionSpy)
+
+ await authService.initialize()
+
+ expect(authService.getState()).toBe("inactive-session")
+ expect(inactiveSessionSpy).toHaveBeenCalledWith({ previousState: "initializing" })
+ expect(mockTimer.start).toHaveBeenCalled()
+ })
+
+ it("should handle invalid credentials gracefully", async () => {
+ mockContext.secrets.get.mockResolvedValue("invalid-json")
+
+ const loggedOutSpy = vi.fn()
+ authService.on("logged-out", loggedOutSpy)
+
+ await authService.initialize()
+
+ expect(authService.getState()).toBe("logged-out")
+ expect(mockLog).toHaveBeenCalledWith("[auth] Failed to parse stored credentials:", expect.any(Error))
+ })
+
+ it("should handle credentials change events", async () => {
+ let onDidChangeCallback: (e: { key: string }) => void
+
+ mockContext.secrets.onDidChange.mockImplementation((callback: (e: { key: string }) => void) => {
+ onDidChangeCallback = callback
+ return { dispose: vi.fn() }
+ })
+
+ await authService.initialize()
+
+ // Simulate credentials change event
+ const newCredentials = { clientToken: "new-token", sessionId: "new-session" }
+ mockContext.secrets.get.mockResolvedValue(JSON.stringify(newCredentials))
+
+ const inactiveSessionSpy = vi.fn()
+ authService.on("inactive-session", inactiveSessionSpy)
+
+ onDidChangeCallback!({ key: "clerk-auth-credentials" })
+ await new Promise((resolve) => setTimeout(resolve, 0)) // Wait for async handling
+
+ expect(inactiveSessionSpy).toHaveBeenCalled()
+ })
+ })
+
+ describe("login", () => {
+ beforeEach(async () => {
+ await authService.initialize()
+ })
+
+ it("should generate state and open external URL", async () => {
+ const mockOpenExternal = vi.fn()
+ const vscode = await import("vscode")
+ vi.mocked(vscode.env.openExternal).mockImplementation(mockOpenExternal)
+
+ await authService.login()
+
+ expect(crypto.randomBytes).toHaveBeenCalledWith(16)
+ expect(mockContext.globalState.update).toHaveBeenCalledWith(
+ "clerk-auth-state",
+ "746573742d72616e646f6d2d6279746573",
+ )
+ expect(mockOpenExternal).toHaveBeenCalledWith(
+ expect.objectContaining({
+ toString: expect.any(Function),
+ }),
+ )
+ })
+
+ it("should use package.json values for redirect URI", async () => {
+ const mockOpenExternal = vi.fn()
+ const vscode = await import("vscode")
+ vi.mocked(vscode.env.openExternal).mockImplementation(mockOpenExternal)
+
+ await authService.login()
+
+ const expectedUrl =
+ "https://api.test.com/extension/sign-in?state=746573742d72616e646f6d2d6279746573&auth_redirect=vscode%3A%2F%2FRooVeterinaryInc.roo-cline"
+ expect(mockOpenExternal).toHaveBeenCalledWith(
+ expect.objectContaining({
+ toString: expect.any(Function),
+ }),
+ )
+
+ // Verify the actual URL
+ const calledUri = mockOpenExternal.mock.calls[0][0]
+ expect(calledUri.toString()).toBe(expectedUrl)
+ })
+
+ it("should handle errors during login", async () => {
+ vi.mocked(crypto.randomBytes).mockImplementation(() => {
+ throw new Error("Crypto error")
+ })
+
+ await expect(authService.login()).rejects.toThrow("Failed to initiate Roo Code Cloud authentication")
+ expect(mockLog).toHaveBeenCalledWith("[auth] Error initiating Roo Code Cloud auth: Error: Crypto error")
+ })
+ })
+
+ describe("handleCallback", () => {
+ beforeEach(async () => {
+ await authService.initialize()
+ })
+
+ it("should handle invalid parameters", async () => {
+ const vscode = await import("vscode")
+ const mockShowInfo = vi.fn()
+ vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo)
+
+ await authService.handleCallback(null, "state")
+ expect(mockShowInfo).toHaveBeenCalledWith("Invalid Roo Code Cloud sign in url")
+
+ await authService.handleCallback("code", null)
+ expect(mockShowInfo).toHaveBeenCalledWith("Invalid Roo Code Cloud sign in url")
+ })
+
+ it("should validate state parameter", async () => {
+ mockContext.globalState.get.mockReturnValue("stored-state")
+
+ await expect(authService.handleCallback("code", "different-state")).rejects.toThrow(
+ "Failed to handle Roo Code Cloud callback",
+ )
+ expect(mockLog).toHaveBeenCalledWith("[auth] State mismatch in callback")
+ })
+
+ it("should successfully handle valid callback", async () => {
+ const storedState = "valid-state"
+ mockContext.globalState.get.mockReturnValue(storedState)
+
+ // Mock successful Clerk sign-in response
+ const mockResponse = {
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ response: { created_session_id: "session-123" },
+ }),
+ headers: {
+ get: (header: string) => (header === "authorization" ? "Bearer token-123" : null),
+ },
+ }
+ mockFetch.mockResolvedValue(mockResponse)
+
+ const vscode = await import("vscode")
+ const mockShowInfo = vi.fn()
+ vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo)
+
+ await authService.handleCallback("auth-code", storedState)
+
+ expect(mockContext.secrets.store).toHaveBeenCalledWith(
+ "clerk-auth-credentials",
+ JSON.stringify({ clientToken: "Bearer token-123", sessionId: "session-123" }),
+ )
+ expect(mockShowInfo).toHaveBeenCalledWith("Successfully authenticated with Roo Code Cloud")
+ })
+
+ it("should handle Clerk API errors", async () => {
+ const storedState = "valid-state"
+ mockContext.globalState.get.mockReturnValue(storedState)
+
+ mockFetch.mockResolvedValue({
+ ok: false,
+ status: 400,
+ statusText: "Bad Request",
+ })
+
+ const loggedOutSpy = vi.fn()
+ authService.on("logged-out", loggedOutSpy)
+
+ await expect(authService.handleCallback("auth-code", storedState)).rejects.toThrow(
+ "Failed to handle Roo Code Cloud callback",
+ )
+ expect(loggedOutSpy).toHaveBeenCalled()
+ })
+ })
+
+ describe("logout", () => {
+ beforeEach(async () => {
+ await authService.initialize()
+ })
+
+ it("should clear credentials and call Clerk logout", async () => {
+ // Set up credentials first by simulating a login state
+ const credentials = { clientToken: "test-token", sessionId: "test-session" }
+
+ // Manually set the credentials in the service
+ authService["credentials"] = credentials
+
+ // Mock successful logout response
+ mockFetch.mockResolvedValue({ ok: true })
+
+ const vscode = await import("vscode")
+ const mockShowInfo = vi.fn()
+ vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo)
+
+ await authService.logout()
+
+ expect(mockContext.secrets.delete).toHaveBeenCalledWith("clerk-auth-credentials")
+ expect(mockContext.globalState.update).toHaveBeenCalledWith("clerk-auth-state", undefined)
+ expect(mockFetch).toHaveBeenCalledWith(
+ "https://clerk.test.com/v1/client/sessions/test-session/remove",
+ expect.objectContaining({
+ method: "POST",
+ headers: expect.objectContaining({
+ Authorization: "Bearer test-token",
+ }),
+ }),
+ )
+ expect(mockShowInfo).toHaveBeenCalledWith("Logged out from Roo Code Cloud")
+ })
+
+ it("should handle logout without credentials", async () => {
+ const vscode = await import("vscode")
+ const mockShowInfo = vi.fn()
+ vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo)
+
+ await authService.logout()
+
+ expect(mockContext.secrets.delete).toHaveBeenCalled()
+ expect(mockFetch).not.toHaveBeenCalled()
+ expect(mockShowInfo).toHaveBeenCalledWith("Logged out from Roo Code Cloud")
+ })
+
+ it("should handle Clerk logout errors gracefully", async () => {
+ // Set up credentials first by simulating a login state
+ const credentials = { clientToken: "test-token", sessionId: "test-session" }
+
+ // Manually set the credentials in the service
+ authService["credentials"] = credentials
+
+ // Mock failed logout response
+ mockFetch.mockRejectedValue(new Error("Network error"))
+
+ const vscode = await import("vscode")
+ const mockShowInfo = vi.fn()
+ vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo)
+
+ await authService.logout()
+
+ expect(mockLog).toHaveBeenCalledWith("[auth] Error calling clerkLogout:", expect.any(Error))
+ expect(mockShowInfo).toHaveBeenCalledWith("Logged out from Roo Code Cloud")
+ })
+ })
+
+ describe("state management", () => {
+ it("should return correct state", () => {
+ expect(authService.getState()).toBe("initializing")
+ })
+
+ it("should return correct authentication status", async () => {
+ await authService.initialize()
+ expect(authService.isAuthenticated()).toBe(false)
+
+ // Create a new service instance with credentials
+ const credentials = { clientToken: "test-token", sessionId: "test-session" }
+ mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
+
+ const authenticatedService = new AuthService(mockContext as unknown as vscode.ExtensionContext, mockLog)
+ await authenticatedService.initialize()
+
+ expect(authenticatedService.isAuthenticated()).toBe(true)
+ expect(authenticatedService.hasActiveSession()).toBe(false)
+ })
+
+ it("should return session token only for active sessions", () => {
+ expect(authService.getSessionToken()).toBeUndefined()
+
+ // Manually set state to active-session for testing
+ // This would normally happen through refreshSession
+ authService["state"] = "active-session"
+ authService["sessionToken"] = "test-jwt"
+
+ expect(authService.getSessionToken()).toBe("test-jwt")
+ })
+ })
+
+ describe("session refresh", () => {
+ beforeEach(async () => {
+ // Set up with credentials
+ const credentials = { clientToken: "test-token", sessionId: "test-session" }
+ mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
+ await authService.initialize()
+ })
+
+ it("should refresh session successfully", async () => {
+ // Mock successful token creation and user info fetch
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ jwt: "new-jwt-token" }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ response: {
+ first_name: "John",
+ last_name: "Doe",
+ image_url: "https://example.com/avatar.jpg",
+ primary_email_address_id: "email-1",
+ email_addresses: [{ id: "email-1", email_address: "john@example.com" }],
+ },
+ }),
+ })
+
+ const activeSessionSpy = vi.fn()
+ const userInfoSpy = vi.fn()
+ authService.on("active-session", activeSessionSpy)
+ authService.on("user-info", userInfoSpy)
+
+ // Trigger refresh by calling the timer callback
+ const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback
+ await timerCallback()
+
+ // Wait for async operations to complete
+ await new Promise((resolve) => setTimeout(resolve, 0))
+
+ expect(authService.getState()).toBe("active-session")
+ expect(authService.hasActiveSession()).toBe(true)
+ expect(authService.getSessionToken()).toBe("new-jwt-token")
+ expect(activeSessionSpy).toHaveBeenCalledWith({ previousState: "inactive-session" })
+ expect(userInfoSpy).toHaveBeenCalledWith({
+ userInfo: {
+ name: "John Doe",
+ email: "john@example.com",
+ picture: "https://example.com/avatar.jpg",
+ },
+ })
+ })
+
+ it("should handle invalid client token error", async () => {
+ // Mock 401 response (invalid token)
+ mockFetch.mockResolvedValue({
+ ok: false,
+ status: 401,
+ statusText: "Unauthorized",
+ })
+
+ const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback
+
+ await expect(timerCallback()).rejects.toThrow()
+ expect(mockContext.secrets.delete).toHaveBeenCalledWith("clerk-auth-credentials")
+ expect(mockLog).toHaveBeenCalledWith("[auth] Invalid/Expired client token: clearing credentials")
+ })
+
+ it("should handle network errors during refresh", async () => {
+ mockFetch.mockRejectedValue(new Error("Network error"))
+
+ const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback
+
+ await expect(timerCallback()).rejects.toThrow("Network error")
+ expect(mockLog).toHaveBeenCalledWith("[auth] Failed to refresh session", expect.any(Error))
+ })
+ })
+
+ describe("user info", () => {
+ it("should return null initially", () => {
+ expect(authService.getUserInfo()).toBeNull()
+ })
+
+ it("should parse user info correctly", async () => {
+ // Set up with credentials
+ const credentials = { clientToken: "test-token", sessionId: "test-session" }
+ mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
+ await authService.initialize()
+
+ // Clear previous mock calls
+ mockFetch.mockClear()
+
+ // Mock successful responses
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ jwt: "jwt-token" }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ response: {
+ first_name: "Jane",
+ last_name: "Smith",
+ image_url: "https://example.com/jane.jpg",
+ primary_email_address_id: "email-2",
+ email_addresses: [
+ { id: "email-1", email_address: "jane.old@example.com" },
+ { id: "email-2", email_address: "jane@example.com" },
+ ],
+ },
+ }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ response: [
+ {
+ id: "org_member_id_1",
+ role: "member",
+ organization: {
+ id: "org_1",
+ name: "Org 1",
+ },
+ },
+ ],
+ }),
+ })
+
+ const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback
+ await timerCallback()
+
+ // Wait for async operations to complete
+ await new Promise((resolve) => setTimeout(resolve, 0))
+
+ const userInfo = authService.getUserInfo()
+ expect(userInfo).toEqual({
+ name: "Jane Smith",
+ email: "jane@example.com",
+ picture: "https://example.com/jane.jpg",
+ organizationId: "org_1",
+ organizationName: "Org 1",
+ organizationRole: "member",
+ })
+ })
+
+ it("should handle missing user info fields", async () => {
+ // Set up with credentials
+ const credentials = { clientToken: "test-token", sessionId: "test-session" }
+ mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
+ await authService.initialize()
+
+ // Clear previous mock calls
+ mockFetch.mockClear()
+
+ // Mock responses with minimal data
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ jwt: "jwt-token" }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ response: {
+ first_name: "John",
+ last_name: "Doe",
+ // Missing other fields
+ },
+ }),
+ })
+
+ const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback
+ await timerCallback()
+
+ // Wait for async operations to complete
+ await new Promise((resolve) => setTimeout(resolve, 0))
+
+ const userInfo = authService.getUserInfo()
+ expect(userInfo).toEqual({
+ name: "John Doe",
+ email: undefined,
+ picture: undefined,
+ })
+ })
+ })
+
+ describe("event emissions", () => {
+ it("should emit logged-out event", async () => {
+ const loggedOutSpy = vi.fn()
+ authService.on("logged-out", loggedOutSpy)
+
+ await authService.initialize()
+
+ expect(loggedOutSpy).toHaveBeenCalledWith({ previousState: "initializing" })
+ })
+
+ it("should emit inactive-session event", async () => {
+ const credentials = { clientToken: "test-token", sessionId: "test-session" }
+ mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
+
+ const inactiveSessionSpy = vi.fn()
+ authService.on("inactive-session", inactiveSessionSpy)
+
+ await authService.initialize()
+
+ expect(inactiveSessionSpy).toHaveBeenCalledWith({ previousState: "initializing" })
+ })
+
+ it("should emit active-session event", async () => {
+ // Set up with credentials
+ const credentials = { clientToken: "test-token", sessionId: "test-session" }
+ mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
+ await authService.initialize()
+
+ // Clear previous mock calls
+ mockFetch.mockClear()
+
+ // Mock both the token creation and user info fetch
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ jwt: "jwt-token" }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ response: {
+ first_name: "Test",
+ last_name: "User",
+ },
+ }),
+ })
+
+ const activeSessionSpy = vi.fn()
+ authService.on("active-session", activeSessionSpy)
+
+ const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback
+ await timerCallback()
+
+ // Wait for async operations to complete
+ await new Promise((resolve) => setTimeout(resolve, 0))
+
+ expect(activeSessionSpy).toHaveBeenCalledWith({ previousState: "inactive-session" })
+ })
+
+ it("should emit user-info event", async () => {
+ // Set up with credentials
+ const credentials = { clientToken: "test-token", sessionId: "test-session" }
+ mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
+ await authService.initialize()
+
+ // Clear previous mock calls
+ mockFetch.mockClear()
+
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ jwt: "jwt-token" }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ response: {
+ first_name: "Test",
+ last_name: "User",
+ },
+ }),
+ })
+
+ const userInfoSpy = vi.fn()
+ authService.on("user-info", userInfoSpy)
+
+ const timerCallback = vi.mocked(RefreshTimer).mock.calls[0][0].callback
+ await timerCallback()
+
+ // Wait for async operations to complete
+ await new Promise((resolve) => setTimeout(resolve, 0))
+
+ expect(userInfoSpy).toHaveBeenCalledWith({
+ userInfo: {
+ name: "Test User",
+ email: undefined,
+ picture: undefined,
+ },
+ })
+ })
+ })
+
+ describe("error handling", () => {
+ it("should handle credentials change errors", async () => {
+ mockContext.secrets.get.mockRejectedValue(new Error("Storage error"))
+
+ await authService.initialize()
+
+ expect(mockLog).toHaveBeenCalledWith("[auth] Error handling credentials change:", expect.any(Error))
+ })
+
+ it("should handle malformed JSON in credentials", async () => {
+ mockContext.secrets.get.mockResolvedValue("invalid-json{")
+
+ await authService.initialize()
+
+ expect(authService.getState()).toBe("logged-out")
+ expect(mockLog).toHaveBeenCalledWith("[auth] Failed to parse stored credentials:", expect.any(Error))
+ })
+
+ it("should handle invalid credentials schema", async () => {
+ mockContext.secrets.get.mockResolvedValue(JSON.stringify({ invalid: "data" }))
+
+ await authService.initialize()
+
+ expect(authService.getState()).toBe("logged-out")
+ expect(mockLog).toHaveBeenCalledWith("[auth] Invalid credentials format:", expect.any(Array))
+ })
+
+ it("should handle missing authorization header in sign-in response", async () => {
+ const storedState = "valid-state"
+ mockContext.globalState.get.mockReturnValue(storedState)
+
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ response: { created_session_id: "session-123" },
+ }),
+ headers: {
+ get: () => null, // No authorization header
+ },
+ })
+
+ await expect(authService.handleCallback("auth-code", storedState)).rejects.toThrow(
+ "Failed to handle Roo Code Cloud callback",
+ )
+ })
+ })
+
+ describe("timer integration", () => {
+ it("should stop timer on logged-out transition", async () => {
+ await authService.initialize()
+
+ expect(mockTimer.stop).toHaveBeenCalled()
+ })
+
+ it("should start timer on inactive-session transition", async () => {
+ const credentials = { clientToken: "test-token", sessionId: "test-session" }
+ mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
+
+ await authService.initialize()
+
+ expect(mockTimer.start).toHaveBeenCalled()
+ })
+ })
+})
diff --git a/packages/cloud/src/__tests__/CloudService.test.ts b/packages/cloud/src/__tests__/CloudService.test.ts
index 03b28568d5..c7dc07fcae 100644
--- a/packages/cloud/src/__tests__/CloudService.test.ts
+++ b/packages/cloud/src/__tests__/CloudService.test.ts
@@ -79,7 +79,7 @@ describe("CloudService", () => {
} as unknown as vscode.ExtensionContext
mockAuthService = {
- initialize: vi.fn(),
+ initialize: vi.fn().mockResolvedValue(undefined),
login: vi.fn(),
logout: vi.fn(),
isAuthenticated: vi.fn().mockReturnValue(false),
@@ -108,11 +108,8 @@ describe("CloudService", () => {
},
}
- vi.mocked(AuthService.createInstance).mockResolvedValue(mockAuthService as unknown as AuthService)
- Object.defineProperty(AuthService, "instance", { get: () => mockAuthService, configurable: true })
-
- vi.mocked(SettingsService.createInstance).mockResolvedValue(mockSettingsService as unknown as SettingsService)
- Object.defineProperty(SettingsService, "instance", { get: () => mockSettingsService, configurable: true })
+ vi.mocked(AuthService).mockImplementation(() => mockAuthService as unknown as AuthService)
+ vi.mocked(SettingsService).mockImplementation(() => mockSettingsService as unknown as SettingsService)
vi.mocked(TelemetryService.hasInstance).mockReturnValue(true)
Object.defineProperty(TelemetryService, "instance", {
@@ -135,8 +132,8 @@ describe("CloudService", () => {
const cloudService = await CloudService.createInstance(mockContext, callbacks)
expect(cloudService).toBeInstanceOf(CloudService)
- expect(AuthService.createInstance).toHaveBeenCalledWith(mockContext, expect.any(Function))
- expect(SettingsService.createInstance).toHaveBeenCalledWith(mockContext, expect.any(Function))
+ expect(AuthService).toHaveBeenCalledWith(mockContext, expect.any(Function))
+ expect(SettingsService).toHaveBeenCalledWith(mockContext, mockAuthService, expect.any(Function))
})
it("should throw error if instance already exists", async () => {
diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json
index 25c842089b..42d0df387c 100644
--- a/packages/telemetry/package.json
+++ b/packages/telemetry/package.json
@@ -12,7 +12,7 @@
},
"dependencies": {
"@roo-code/types": "workspace:^",
- "posthog-node": "^4.7.0",
+ "posthog-node": "^5.0.0",
"zod": "^3.25.61"
},
"devDependencies": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 53f00bbb91..bf75ff0f5d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -515,8 +515,8 @@ importers:
specifier: workspace:^
version: link:../types
posthog-node:
- specifier: ^4.7.0
- version: 4.17.2
+ specifier: ^5.0.0
+ version: 5.1.1
zod:
specifier: ^3.25.61
version: 3.25.61
@@ -703,8 +703,8 @@ importers:
specifier: ^1.2.0
version: 1.2.0
puppeteer-chromium-resolver:
- specifier: ^23.0.0
- version: 23.0.0
+ specifier: ^24.0.0
+ version: 24.0.1
puppeteer-core:
specifier: ^23.4.0
version: 23.11.1
@@ -2040,9 +2040,6 @@ packages:
resolution: {integrity: sha512-qFlpmObPqeUs4u3oFYv/OM/xyX+pNa5TRAjqjvMhbGYlyMhzSrE5UfncL2rUcEeVfD9Gebgff73hPwqcOwJQNA==}
engines: {node: '>=18'}
- '@napi-rs/wasm-runtime@0.2.10':
- resolution: {integrity: sha512-bCsCyeZEwVErsGmyPNSzwfwFn4OdxBj0mmv6hOFucB/k81Ojdu68RbZdxYsRQUPc9l6SU5F/cG+bXgWs3oUgsQ==}
-
'@napi-rs/wasm-runtime@0.2.11':
resolution: {integrity: sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==}
@@ -4467,6 +4464,11 @@ packages:
peerDependencies:
devtools-protocol: '*'
+ chromium-bidi@5.1.0:
+ resolution: {integrity: sha512-9MSRhWRVoRPDG0TgzkHrshFSJJNZzfY5UFqUMuksg7zL1yoZIZ3jLB0YAgHclbiAxPI86pBnwDX1tbzoiV8aFw==}
+ peerDependencies:
+ devtools-protocol: '*'
+
ci-info@2.0.0:
resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==}
@@ -5007,6 +5009,9 @@ packages:
devtools-protocol@0.0.1367902:
resolution: {integrity: sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg==}
+ devtools-protocol@0.0.1452169:
+ resolution: {integrity: sha512-FOFDVMGrAUNp0dDKsAU1TorWJUx2JOU1k9xdgBKKJF3IBh/Uhl2yswG5r3TEAOrCiGY2QRp1e6LVDQrCsTKO4g==}
+
didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
@@ -7753,9 +7758,9 @@ packages:
rrweb-snapshot:
optional: true
- posthog-node@4.17.2:
- resolution: {integrity: sha512-bFmwOTk4QdYavopeHVXtyFGQ9vyLMVaNWkWocwjix+0n6sQgv7Zq5nYjYulz7ThmK18zsvNJ337ahuMLv3ulow==}
- engines: {node: '>=15.0.0'}
+ posthog-node@5.1.1:
+ resolution: {integrity: sha512-6VISkNdxO24ehXiDA4dugyCSIV7lpGVaEu5kn/dlAj+SJ1lgcDru9PQ8p/+GSXsXVxohd1t7kHL2JKc9NoGb0w==}
+ engines: {node: '>=20'}
preact@10.26.6:
resolution: {integrity: sha512-5SRRBinwpwkaD+OqlBDeITlRgvd8I8QlxHJw9AxSdMNV6O+LodN9nUyYGpSF7sadHjs6RzeFShMexC6DbtWr9g==}
@@ -7841,8 +7846,8 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
- puppeteer-chromium-resolver@23.0.0:
- resolution: {integrity: sha512-PbSXK4ERPwp+eYm+SVY5vMWCxsdeJcddwz4avXvDx7kE9DLE+L86Xg027sypw2oan5yi6557brzVsbajcMmy2g==}
+ puppeteer-chromium-resolver@24.0.1:
+ resolution: {integrity: sha512-whu9e5qmnZekCP5hvlYMe7rWe4cU9seCISRlfT0vXGlCsy7psbeXHdGW6QdXrwyadvCTiD1Ft62jPaqia8ZQaA==}
puppeteer-core@23.11.1:
resolution: {integrity: sha512-3HZ2/7hdDKZvZQ7dhhITOUg4/wOrDRjyK2ZBllRB0ZCOi9u0cwq1ACHDjBB+nX+7+kltHjQvBRdeY7+W0T+7Gg==}
@@ -10966,13 +10971,6 @@ snapshots:
outvariant: 1.4.3
strict-event-emitter: 0.5.1
- '@napi-rs/wasm-runtime@0.2.10':
- dependencies:
- '@emnapi/core': 1.4.3
- '@emnapi/runtime': 1.4.3
- '@tybys/wasm-util': 0.9.0
- optional: true
-
'@napi-rs/wasm-runtime@0.2.11':
dependencies:
'@emnapi/core': 1.4.3
@@ -11053,7 +11051,7 @@ snapshots:
'@node-rs/crc32-wasm32-wasi@1.10.6':
dependencies:
- '@napi-rs/wasm-runtime': 0.2.10
+ '@napi-rs/wasm-runtime': 0.2.11
optional: true
'@node-rs/crc32-win32-arm64-msvc@1.10.6':
@@ -13653,6 +13651,12 @@ snapshots:
mitt: 3.0.1
zod: 3.23.8
+ chromium-bidi@5.1.0(devtools-protocol@0.0.1452169):
+ dependencies:
+ devtools-protocol: 0.0.1452169
+ mitt: 3.0.1
+ zod: 3.25.67
+
ci-info@2.0.0: {}
ci-info@3.9.0: {}
@@ -14192,6 +14196,8 @@ snapshots:
devtools-protocol@0.0.1367902: {}
+ devtools-protocol@0.0.1452169: {}
+
didyoumean@1.2.2: {}
diff-match-patch@1.0.5: {}
@@ -17354,11 +17360,7 @@ snapshots:
preact: 10.26.6
web-vitals: 4.2.4
- posthog-node@4.17.2:
- dependencies:
- axios: 1.9.0
- transitivePeerDependencies:
- - debug
+ posthog-node@5.1.1: {}
preact@10.26.6: {}
@@ -17452,12 +17454,12 @@ snapshots:
punycode@2.3.1: {}
- puppeteer-chromium-resolver@23.0.0:
+ puppeteer-chromium-resolver@24.0.1:
dependencies:
'@puppeteer/browsers': 2.10.5
eight-colors: 1.3.1
gauge: 5.0.2
- puppeteer-core: 23.11.1
+ puppeteer-core: 24.10.1
transitivePeerDependencies:
- bare-buffer
- bufferutil
diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts
index 412f5de621..52dec1ae55 100644
--- a/src/api/providers/anthropic.ts
+++ b/src/api/providers/anthropic.ts
@@ -17,6 +17,7 @@ import { getModelParams } from "../transform/model-params"
import { BaseProvider } from "./base-provider"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
+import { calculateApiCostAnthropic } from "../../shared/cost"
export class AnthropicHandler extends BaseProvider implements SingleCompletionHandler {
private options: ApiHandlerOptions
@@ -132,20 +133,35 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
}
}
+ let inputTokens = 0
+ let outputTokens = 0
+ let cacheWriteTokens = 0
+ let cacheReadTokens = 0
+
for await (const chunk of stream) {
switch (chunk.type) {
case "message_start": {
// Tells us cache reads/writes/input/output.
- const usage = chunk.message.usage
+ const {
+ input_tokens = 0,
+ output_tokens = 0,
+ cache_creation_input_tokens,
+ cache_read_input_tokens,
+ } = chunk.message.usage
yield {
type: "usage",
- inputTokens: usage.input_tokens || 0,
- outputTokens: usage.output_tokens || 0,
- cacheWriteTokens: usage.cache_creation_input_tokens || undefined,
- cacheReadTokens: usage.cache_read_input_tokens || undefined,
+ inputTokens: input_tokens,
+ outputTokens: output_tokens,
+ cacheWriteTokens: cache_creation_input_tokens || undefined,
+ cacheReadTokens: cache_read_input_tokens || undefined,
}
+ inputTokens += input_tokens
+ outputTokens += output_tokens
+ cacheWriteTokens += cache_creation_input_tokens || 0
+ cacheReadTokens += cache_read_input_tokens || 0
+
break
}
case "message_delta":
@@ -198,6 +214,21 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
break
}
}
+
+ if (inputTokens > 0 || outputTokens > 0 || cacheWriteTokens > 0 || cacheReadTokens > 0) {
+ yield {
+ type: "usage",
+ inputTokens: 0,
+ outputTokens: 0,
+ totalCost: calculateApiCostAnthropic(
+ this.getModel().info,
+ inputTokens,
+ outputTokens,
+ cacheWriteTokens,
+ cacheReadTokens,
+ ),
+ }
+ }
}
getModel() {
diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts
index 503eb0e7cf..025704d215 100644
--- a/src/core/task/Task.ts
+++ b/src/core/task/Task.ts
@@ -1460,19 +1460,22 @@ export class Task extends EventEmitter {
} finally {
this.isStreaming = false
}
- if (
- inputTokens > 0 ||
- outputTokens > 0 ||
- cacheWriteTokens > 0 ||
- cacheReadTokens > 0 ||
- typeof totalCost !== "undefined"
- ) {
+
+ if (inputTokens > 0 || outputTokens > 0 || cacheWriteTokens > 0 || cacheReadTokens > 0) {
TelemetryService.instance.captureLlmCompletion(this.taskId, {
inputTokens,
outputTokens,
cacheWriteTokens,
cacheReadTokens,
- cost: totalCost,
+ cost:
+ totalCost ??
+ calculateApiCostAnthropic(
+ this.api.getModel().info,
+ inputTokens,
+ outputTokens,
+ cacheWriteTokens,
+ cacheReadTokens,
+ ),
})
}
diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts
index efa49f268d..de4f34a12a 100644
--- a/src/core/webview/__tests__/ClineProvider.spec.ts
+++ b/src/core/webview/__tests__/ClineProvider.spec.ts
@@ -569,6 +569,117 @@ describe("ClineProvider", () => {
expect(stackSizeBeforeAbort - stackSizeAfterAbort).toBe(1)
})
+ describe("clearTask message handler", () => {
+ beforeEach(async () => {
+ await provider.resolveWebviewView(mockWebviewView)
+ })
+
+ test("calls clearTask when there is no parent task", async () => {
+ // Setup a single task without parent
+ const mockCline = new Task(defaultTaskOptions)
+ // No need to set parentTask - it's undefined by default
+
+ // Mock the provider methods
+ const clearTaskSpy = vi.spyOn(provider, "clearTask").mockResolvedValue(undefined)
+ const finishSubTaskSpy = vi.spyOn(provider, "finishSubTask").mockResolvedValue(undefined)
+ const postStateToWebviewSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined)
+
+ // Add task to stack
+ await provider.addClineToStack(mockCline)
+
+ // Get the message handler
+ const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0]
+
+ // Trigger clearTask message
+ await messageHandler({ type: "clearTask" })
+
+ // Verify clearTask was called (not finishSubTask)
+ expect(clearTaskSpy).toHaveBeenCalled()
+ expect(finishSubTaskSpy).not.toHaveBeenCalled()
+ expect(postStateToWebviewSpy).toHaveBeenCalled()
+ })
+
+ test("calls finishSubTask when there is a parent task", async () => {
+ // Setup parent and child tasks
+ const parentTask = new Task(defaultTaskOptions)
+ const childTask = new Task(defaultTaskOptions)
+
+ // Set up parent-child relationship by setting the parentTask property
+ // The mock allows us to set properties directly
+ ;(childTask as any).parentTask = parentTask
+ ;(childTask as any).rootTask = parentTask
+
+ // Mock the provider methods
+ const clearTaskSpy = vi.spyOn(provider, "clearTask").mockResolvedValue(undefined)
+ const finishSubTaskSpy = vi.spyOn(provider, "finishSubTask").mockResolvedValue(undefined)
+ const postStateToWebviewSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined)
+
+ // Add both tasks to stack (parent first, then child)
+ await provider.addClineToStack(parentTask)
+ await provider.addClineToStack(childTask)
+
+ // Get the message handler
+ const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0]
+
+ // Trigger clearTask message
+ await messageHandler({ type: "clearTask" })
+
+ // Verify finishSubTask was called (not clearTask)
+ expect(finishSubTaskSpy).toHaveBeenCalledWith(expect.stringContaining("canceled"))
+ expect(clearTaskSpy).not.toHaveBeenCalled()
+ expect(postStateToWebviewSpy).toHaveBeenCalled()
+ })
+
+ test("handles case when no current task exists", async () => {
+ // Don't add any tasks to the stack
+
+ // Mock the provider methods
+ const clearTaskSpy = vi.spyOn(provider, "clearTask").mockResolvedValue(undefined)
+ const finishSubTaskSpy = vi.spyOn(provider, "finishSubTask").mockResolvedValue(undefined)
+ const postStateToWebviewSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined)
+
+ // Get the message handler
+ const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0]
+
+ // Trigger clearTask message
+ await messageHandler({ type: "clearTask" })
+
+ // When there's no current task, clearTask is still called (it handles the no-task case internally)
+ expect(clearTaskSpy).toHaveBeenCalled()
+ expect(finishSubTaskSpy).not.toHaveBeenCalled()
+ // State should still be posted
+ expect(postStateToWebviewSpy).toHaveBeenCalled()
+ })
+
+ test("correctly identifies subtask scenario for issue #4602", async () => {
+ // This test specifically validates the fix for issue #4602
+ // where canceling during API retry was incorrectly treating a single task as a subtask
+
+ const mockCline = new Task(defaultTaskOptions)
+ // No parent task by default - no need to explicitly set
+
+ // Mock the provider methods
+ const clearTaskSpy = vi.spyOn(provider, "clearTask").mockResolvedValue(undefined)
+ const finishSubTaskSpy = vi.spyOn(provider, "finishSubTask").mockResolvedValue(undefined)
+
+ // Add only one task to stack
+ await provider.addClineToStack(mockCline)
+
+ // Verify stack size is 1
+ expect(provider.getClineStackSize()).toBe(1)
+
+ // Get the message handler
+ const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0]
+
+ // Trigger clearTask message (simulating cancel during API retry)
+ await messageHandler({ type: "clearTask" })
+
+ // The fix ensures clearTask is called, not finishSubTask
+ expect(clearTaskSpy).toHaveBeenCalled()
+ expect(finishSubTaskSpy).not.toHaveBeenCalled()
+ })
+ })
+
test("addClineToStack adds multiple Cline instances to the stack", async () => {
// Setup Cline instance with auto-mock from the top of the file
const mockCline1 = new Task(defaultTaskOptions) // Create a new mocked instance
diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts
index cfd5182670..9a9ae9e8b8 100644
--- a/src/core/webview/webviewMessageHandler.ts
+++ b/src/core/webview/webviewMessageHandler.ts
@@ -251,7 +251,14 @@ export const webviewMessageHandler = async (
break
case "clearTask":
// clear task resets the current session and allows for a new task to be started, if this session is a subtask - it allows the parent task to be resumed
- await provider.finishSubTask(t("common:tasks.canceled"))
+ // Check if the current task actually has a parent task
+ const currentTask = provider.getCurrentCline()
+ if (currentTask && currentTask.parentTask) {
+ await provider.finishSubTask(t("common:tasks.canceled"))
+ } else {
+ // Regular task - just clear it
+ await provider.clearTask()
+ }
await provider.postStateToWebview()
break
case "didShowAnnouncement":
diff --git a/src/package.json b/src/package.json
index f6c7027b4a..327f2b13db 100644
--- a/src/package.json
+++ b/src/package.json
@@ -410,7 +410,7 @@
"pkce-challenge": "^5.0.0",
"pretty-bytes": "^6.1.1",
"ps-tree": "^1.2.0",
- "puppeteer-chromium-resolver": "^23.0.0",
+ "puppeteer-chromium-resolver": "^24.0.0",
"puppeteer-core": "^23.4.0",
"reconnecting-eventsource": "^1.6.4",
"sanitize-filename": "^1.6.3",
diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts
index ddfca7fc6d..a403c4e851 100644
--- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts
+++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts
@@ -12,7 +12,7 @@ import * as fileSearch from "../../../services/search/file-search"
import { RepoPerTaskCheckpointService } from "../RepoPerTaskCheckpointService"
-vitest.setConfig({ testTimeout: 10_000 })
+vitest.setConfig({ testTimeout: 20_000 })
const tmpDir = path.join(os.tmpdir(), "CheckpointService")
diff --git a/src/services/tree-sitter/languageParser.ts b/src/services/tree-sitter/languageParser.ts
index 336f9919c0..67c906f04d 100644
--- a/src/services/tree-sitter/languageParser.ts
+++ b/src/services/tree-sitter/languageParser.ts
@@ -43,19 +43,13 @@ async function loadLanguage(langName: string) {
let isParserInitialized = false
-async function initializeParser() {
- if (!isParserInitialized) {
- await Parser.init()
- isParserInitialized = true
- }
-}
-
/*
Using node bindings for tree-sitter is problematic in vscode extensions
because of incompatibility with electron. Going the .wasm route has the
advantage of not having to build for multiple architectures.
-We use web-tree-sitter and tree-sitter-wasms which provides auto-updating prebuilt WASM binaries for tree-sitter's language parsers.
+We use web-tree-sitter and tree-sitter-wasms which provides auto-updating
+prebuilt WASM binaries for tree-sitter's language parsers.
This function loads WASM modules for relevant language parsers based on input files:
1. Extracts unique file extensions
@@ -72,142 +66,157 @@ Sources:
- https://github.com/tree-sitter/tree-sitter/blob/master/lib/binding_web/README.md
- https://github.com/tree-sitter/tree-sitter/blob/master/lib/binding_web/test/query-test.js
*/
-export async function loadRequiredLanguageParsers(filesToParse: string[]): Promise {
- await initializeParser()
+export async function loadRequiredLanguageParsers(filesToParse: string[], sourceDirectory?: string) {
+ const { Parser, Query } = require("web-tree-sitter")
+
+ if (!isParserInitialized) {
+ try {
+ await Parser.init()
+ isParserInitialized = true
+ } catch (error) {
+ console.error(`Error initializing parser: ${error instanceof Error ? error.message : error}`)
+ throw error
+ }
+ }
+
const extensionsToLoad = new Set(filesToParse.map((file) => path.extname(file).toLowerCase().slice(1)))
const parsers: LanguageParser = {}
+
for (const ext of extensionsToLoad) {
let language: Language
let query: Query
let parserKey = ext // Default to using extension as key
+
switch (ext) {
case "js":
case "jsx":
case "json":
- language = await loadLanguage("javascript")
- query = language.query(javascriptQuery)
+ language = await loadLanguage("javascript", sourceDirectory)
+ query = new Query(language, javascriptQuery)
break
case "ts":
- language = await loadLanguage("typescript")
- query = language.query(typescriptQuery)
+ language = await loadLanguage("typescript", sourceDirectory)
+ query = new Query(language, typescriptQuery)
break
case "tsx":
- language = await loadLanguage("tsx")
- query = language.query(tsxQuery)
+ language = await loadLanguage("tsx", sourceDirectory)
+ query = new Query(language, tsxQuery)
break
case "py":
- language = await loadLanguage("python")
- query = language.query(pythonQuery)
+ language = await loadLanguage("python", sourceDirectory)
+ query = new Query(language, pythonQuery)
break
case "rs":
- language = await loadLanguage("rust")
- query = language.query(rustQuery)
+ language = await loadLanguage("rust", sourceDirectory)
+ query = new Query(language, rustQuery)
break
case "go":
- language = await loadLanguage("go")
- query = language.query(goQuery)
+ language = await loadLanguage("go", sourceDirectory)
+ query = new Query(language, goQuery)
break
case "cpp":
case "hpp":
- language = await loadLanguage("cpp")
- query = language.query(cppQuery)
+ language = await loadLanguage("cpp", sourceDirectory)
+ query = new Query(language, cppQuery)
break
case "c":
case "h":
- language = await loadLanguage("c")
- query = language.query(cQuery)
+ language = await loadLanguage("c", sourceDirectory)
+ query = new Query(language, cQuery)
break
case "cs":
- language = await loadLanguage("c_sharp")
- query = language.query(csharpQuery)
+ language = await loadLanguage("c_sharp", sourceDirectory)
+ query = new Query(language, csharpQuery)
break
case "rb":
- language = await loadLanguage("ruby")
- query = language.query(rubyQuery)
+ language = await loadLanguage("ruby", sourceDirectory)
+ query = new Query(language, rubyQuery)
break
case "java":
- language = await loadLanguage("java")
- query = language.query(javaQuery)
+ language = await loadLanguage("java", sourceDirectory)
+ query = new Query(language, javaQuery)
break
case "php":
- language = await loadLanguage("php")
- query = language.query(phpQuery)
+ language = await loadLanguage("php", sourceDirectory)
+ query = new Query(language, phpQuery)
break
case "swift":
- language = await loadLanguage("swift")
- query = language.query(swiftQuery)
+ language = await loadLanguage("swift", sourceDirectory)
+ query = new Query(language, swiftQuery)
break
case "kt":
case "kts":
- language = await loadLanguage("kotlin")
- query = language.query(kotlinQuery)
+ language = await loadLanguage("kotlin", sourceDirectory)
+ query = new Query(language, kotlinQuery)
break
case "css":
- language = await loadLanguage("css")
- query = language.query(cssQuery)
+ language = await loadLanguage("css", sourceDirectory)
+ query = new Query(language, cssQuery)
break
case "html":
- language = await loadLanguage("html")
- query = language.query(htmlQuery)
+ language = await loadLanguage("html", sourceDirectory)
+ query = new Query(language, htmlQuery)
break
case "ml":
case "mli":
- language = await loadLanguage("ocaml")
- query = language.query(ocamlQuery)
+ language = await loadLanguage("ocaml", sourceDirectory)
+ query = new Query(language, ocamlQuery)
break
case "scala":
- language = await loadLanguage("scala")
- query = language.query(luaQuery) // Temporarily use Lua query until Scala is implemented
+ language = await loadLanguage("scala", sourceDirectory)
+ query = new Query(language, luaQuery) // Temporarily use Lua query until Scala is implemented
break
case "sol":
- language = await loadLanguage("solidity")
- query = language.query(solidityQuery)
+ language = await loadLanguage("solidity", sourceDirectory)
+ query = new Query(language, solidityQuery)
break
case "toml":
- language = await loadLanguage("toml")
- query = language.query(tomlQuery)
+ language = await loadLanguage("toml", sourceDirectory)
+ query = new Query(language, tomlQuery)
break
case "vue":
- language = await loadLanguage("vue")
- query = language.query(vueQuery)
+ language = await loadLanguage("vue", sourceDirectory)
+ query = new Query(language, vueQuery)
break
case "lua":
- language = await loadLanguage("lua")
- query = language.query(luaQuery)
+ language = await loadLanguage("lua", sourceDirectory)
+ query = new Query(language, luaQuery)
break
case "rdl":
- language = await loadLanguage("systemrdl")
- query = language.query(systemrdlQuery)
+ language = await loadLanguage("systemrdl", sourceDirectory)
+ query = new Query(language, systemrdlQuery)
break
case "tla":
- language = await loadLanguage("tlaplus")
- query = language.query(tlaPlusQuery)
+ language = await loadLanguage("tlaplus", sourceDirectory)
+ query = new Query(language, tlaPlusQuery)
break
case "zig":
- language = await loadLanguage("zig")
- query = language.query(zigQuery)
+ language = await loadLanguage("zig", sourceDirectory)
+ query = new Query(language, zigQuery)
break
case "ejs":
case "erb":
- language = await loadLanguage("embedded_template")
- parserKey = "embedded_template" // Use same key for both extensions
- query = language.query(embeddedTemplateQuery)
+ parserKey = "embedded_template" // Use same key for both extensions.
+ language = await loadLanguage("embedded_template", sourceDirectory)
+ query = new Query(language, embeddedTemplateQuery)
break
case "el":
- language = await loadLanguage("elisp")
- query = language.query(elispQuery)
+ language = await loadLanguage("elisp", sourceDirectory)
+ query = new Query(language, elispQuery)
break
case "ex":
case "exs":
- language = await loadLanguage("elixir")
- query = language.query(elixirQuery)
+ language = await loadLanguage("elixir", sourceDirectory)
+ query = new Query(language, elixirQuery)
break
default:
throw new Error(`Unsupported language: ${ext}`)
}
+
const parser = new Parser()
parser.setLanguage(language)
parsers[parserKey] = { parser, query }
}
+
return parsers
}
diff --git a/src/shared/cost.ts b/src/shared/cost.ts
index 3257cab16c..a628756b0d 100644
--- a/src/shared/cost.ts
+++ b/src/shared/cost.ts
@@ -15,7 +15,8 @@ function calculateApiCostInternal(
return totalCost
}
-// For Anthropic compliant usage, the input tokens count does NOT include the cached tokens
+// For Anthropic compliant usage, the input tokens count does NOT include the
+// cached tokens.
export function calculateApiCostAnthropic(
modelInfo: ModelInfo,
inputTokens: number,
@@ -23,18 +24,16 @@ export function calculateApiCostAnthropic(
cacheCreationInputTokens?: number,
cacheReadInputTokens?: number,
): number {
- const cacheCreationInputTokensNum = cacheCreationInputTokens || 0
- const cacheReadInputTokensNum = cacheReadInputTokens || 0
return calculateApiCostInternal(
modelInfo,
inputTokens,
outputTokens,
- cacheCreationInputTokensNum,
- cacheReadInputTokensNum,
+ cacheCreationInputTokens || 0,
+ cacheReadInputTokens || 0,
)
}
-// For OpenAI compliant usage, the input tokens count INCLUDES the cached tokens
+// For OpenAI compliant usage, the input tokens count INCLUDES the cached tokens.
export function calculateApiCostOpenAI(
modelInfo: ModelInfo,
inputTokens: number,
@@ -45,6 +44,7 @@ export function calculateApiCostOpenAI(
const cacheCreationInputTokensNum = cacheCreationInputTokens || 0
const cacheReadInputTokensNum = cacheReadInputTokens || 0
const nonCachedInputTokens = Math.max(0, inputTokens - cacheCreationInputTokensNum - cacheReadInputTokensNum)
+
return calculateApiCostInternal(
modelInfo,
nonCachedInputTokens,