From c7f63f7db2adb373564f5a4022582c86d347650b Mon Sep 17 00:00:00 2001 From: valekseev Date: Thu, 19 Jun 2025 23:49:47 +0100 Subject: [PATCH] Merge branch 'main' into work --- .roo/rules-issue-writer/1_workflow.xml | 108 ++- .../2_github_issue_templates.xml | 95 +- .roo/rules-issue-writer/3_best_practices.xml | 48 +- .../4_common_mistakes_to_avoid.xml | 36 +- .../5_github_mcp_tool_usage.xml | 72 +- packages/cloud/src/AuthService.ts | 185 ++-- packages/cloud/src/CloudService.ts | 14 +- packages/cloud/src/SettingsService.ts | 20 +- packages/cloud/src/__mocks__/vscode.ts | 8 + .../cloud/src/__tests__/AuthService.spec.ts | 815 ++++++++++++++++++ .../cloud/src/__tests__/CloudService.test.ts | 13 +- packages/telemetry/package.json | 2 +- pnpm-lock.yaml | 56 +- src/api/providers/anthropic.ts | 41 +- src/core/task/Task.ts | 19 +- .../webview/__tests__/ClineProvider.spec.ts | 111 +++ src/core/webview/webviewMessageHandler.ts | 9 +- src/package.json | 2 +- .../__tests__/ShadowCheckpointService.spec.ts | 2 +- src/services/tree-sitter/languageParser.ts | 143 +-- src/shared/cost.ts | 12 +- 21 files changed, 1485 insertions(+), 326 deletions(-) create mode 100644 packages/cloud/src/__tests__/AuthService.spec.ts 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,