diff --git a/.changeset/stale-towns-bathe.md b/.changeset/stale-towns-bathe.md new file mode 100644 index 00000000000..ac7605fa338 --- /dev/null +++ b/.changeset/stale-towns-bathe.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Jetbrains - Autocomplete Telemetry diff --git a/jetbrains/plugin/build.gradle.kts b/jetbrains/plugin/build.gradle.kts index a87d76d4484..d0de54f4081 100644 --- a/jetbrains/plugin/build.gradle.kts +++ b/jetbrains/plugin/build.gradle.kts @@ -61,7 +61,6 @@ project.afterEvaluate { tasks.findByName(":prepareSandbox")?.inputs?.properties?.put("build_mode", ext.get("debugMode")) } - group = properties("pluginGroup").get() version = properties("pluginVersion").get() @@ -152,60 +151,62 @@ tasks { println("Configuration file generated: ${configFile.absolutePath}") } } - buildPlugin { dependsOn(prepareSandbox) - + // Include the jetbrains directory contents from sandbox in the distribution root doLast { if (ext.get("debugMode") != "idea" && ext.get("debugMode") != "none") { val distributionFile = archiveFile.get().asFile val sandboxPluginsDir = layout.buildDirectory.get().asFile.resolve("idea-sandbox/IC-2024.3/plugins") val jetbrainsDir = sandboxPluginsDir.resolve("jetbrains") - + if (jetbrainsDir.exists() && distributionFile.exists()) { logger.lifecycle("Adding sandbox resources to distribution ZIP...") logger.lifecycle("Sandbox jetbrains dir: ${jetbrainsDir.absolutePath}") logger.lifecycle("Distribution file: ${distributionFile.absolutePath}") - + // Extract the existing ZIP val tempDir = layout.buildDirectory.get().asFile.resolve("temp-dist") tempDir.deleteRecursively() tempDir.mkdirs() - + copy { from(zipTree(distributionFile)) into(tempDir) } - + // Copy jetbrains directory CONTENTS directly to plugin root (not the jetbrains folder itself) val pluginDir = tempDir.resolve(rootProject.name) copy { from(jetbrainsDir) // Copy contents of jetbrains dir - into(pluginDir) // Directly into plugin root + into(pluginDir) // Directly into plugin root } - + // Re-create the ZIP with resources included distributionFile.delete() - ant.invokeMethod("zip", mapOf( - "destfile" to distributionFile.absolutePath, - "basedir" to tempDir.absolutePath - )) - + ant.invokeMethod( + "zip", + mapOf( + "destfile" to distributionFile.absolutePath, + "basedir" to tempDir.absolutePath, + ), + ) + // Clean up temp directory tempDir.deleteRecursively() - + logger.lifecycle("Distribution ZIP updated with sandbox resources at root level") } } } } - + prepareSandbox { dependsOn("generateConfigProperties") duplicatesStrategy = DuplicatesStrategy.INCLUDE - + if (ext.get("debugMode") == "idea") { from("${project.projectDir.absolutePath}/src/main/resources/themes/") { into("${ext.get("debugResource")}/${ext.get("vscodePlugin")}/integrations/theme/default-themes/") @@ -226,14 +227,14 @@ tasks { if (!depfile.exists()) { throw IllegalStateException("missing prodDep.txt") } - + // Handle platform.zip for release mode if (ext.get("debugMode") == "release") { val platformZip = File("platform.zip") if (!platformZip.exists() || platformZip.length() < 1024 * 1024) { throw IllegalStateException("platform.zip file does not exist or is smaller than 1MB. This file is supported through git lfs and needs to be obtained through git lfs") } - + // Extract platform.zip to the platform subdirectory under the project build directory val platformDir = File("${layout.buildDirectory.get().asFile}/platform") platformDir.mkdirs() @@ -243,11 +244,11 @@ tasks { } } } - + val vscodePluginDir = File("./plugins/${ext.get("vscodePlugin")}") val depfile = File("prodDep.txt") val list = mutableListOf() - + // Read dependencies during execution doFirst { depfile.readLines().forEach { line -> @@ -273,7 +274,7 @@ tasks { // Copy VSCode plugin extension from("${vscodePluginDir.path}/extension") { into("$pluginName/${ext.get("vscodePlugin")}") } - + // Copy themes from("src/main/resources/themes/") { into("$pluginName/${ext.get("vscodePlugin")}/integrations/theme/default-themes/") } diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionConstants.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionConstants.kt index ff4811d2bb3..ee587e002a7 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionConstants.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/InlineCompletionConstants.kt @@ -8,10 +8,16 @@ object InlineCompletionConstants { * VSCode extension command ID for inline completion generation. */ const val EXTERNAL_COMMAND_ID = "kilo-code.jetbrains.getInlineCompletions" - + + /** + * Command ID registered in the VSCode extension for tracking acceptance events. + * This matches the command registered in GhostInlineCompletionProvider. + */ + const val INLINE_COMPLETION_ACCEPTED_COMMAND = "kilocode.ghost.inline-completion.accepted" + /** * Default timeout in milliseconds for inline completion requests. * Set to 10 seconds to allow sufficient time for LLM response. */ const val RPC_TIMEOUT_MS = 10000L -} \ No newline at end of file +} diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/KiloCodeInlineCompletionInsertHandler.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/KiloCodeInlineCompletionInsertHandler.kt new file mode 100644 index 00000000000..df25e6654f9 --- /dev/null +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/KiloCodeInlineCompletionInsertHandler.kt @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package ai.kilocode.jetbrains.inline + +import ai.kilocode.jetbrains.core.PluginContext +import ai.kilocode.jetbrains.core.ServiceProxyRegistry +import ai.kilocode.jetbrains.ipc.proxy.interfaces.ExtHostCommandsProxy +import com.intellij.codeInsight.inline.completion.DefaultInlineCompletionInsertHandler +import com.intellij.codeInsight.inline.completion.InlineCompletionInsertEnvironment +import com.intellij.codeInsight.inline.completion.elements.InlineCompletionElement +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project + +/** + * Custom insert handler that triggers telemetry when inline completions are accepted. + * Extends DefaultInlineCompletionInsertHandler to maintain default insertion behavior + * while adding telemetry tracking via RPC to the VSCode extension. + */ +class KiloCodeInlineCompletionInsertHandler( + private val project: Project, +) : DefaultInlineCompletionInsertHandler() { + + private val logger = Logger.getInstance(KiloCodeInlineCompletionInsertHandler::class.java) + + /** + * Called after the completion text has been inserted into the document. + * This is our hook to trigger telemetry tracking. + * + * @param environment Contains information about the insertion context + * @param elements The inline completion elements that were inserted + */ + override fun afterInsertion( + environment: InlineCompletionInsertEnvironment, + elements: List, + ) { + // Note: NOT calling super.afterInsertion() to avoid potential duplicate telemetry + // The default implementation may be empty or may trigger its own telemetry + + // Trigger telemetry via RPC + try { + val proxy = getRPCProxy() + if (proxy != null) { + // Execute the acceptance command asynchronously + // No need to wait for the result as this is fire-and-forget telemetry + proxy.executeContributedCommand( + InlineCompletionConstants.INLINE_COMPLETION_ACCEPTED_COMMAND, + emptyList(), + ) + logger.debug("Triggered inline completion acceptance telemetry") + } else { + logger.warn("Failed to trigger acceptance telemetry - RPC proxy not available") + } + } catch (e: Exception) { + // Don't let telemetry errors affect the user experience + logger.warn("Error triggering acceptance telemetry", e) + } + } + + /** + * Gets the RPC proxy for command execution from the project's PluginContext. + */ + private fun getRPCProxy(): ExtHostCommandsProxy? { + return project.getService(PluginContext::class.java) + ?.getRPCProtocol() + ?.getProxy(ServiceProxyRegistry.ExtHostContext.ExtHostCommands) + } +} diff --git a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/KiloCodeInlineCompletionProvider.kt b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/KiloCodeInlineCompletionProvider.kt index 5a2deddfae4..1e04e5b048a 100644 --- a/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/KiloCodeInlineCompletionProvider.kt +++ b/jetbrains/plugin/src/main/kotlin/ai/kilocode/jetbrains/inline/KiloCodeInlineCompletionProvider.kt @@ -6,7 +6,6 @@ import com.intellij.codeInsight.inline.completion.InlineCompletionProviderID import com.intellij.codeInsight.inline.completion.InlineCompletionRequest import com.intellij.codeInsight.inline.completion.elements.InlineCompletionGrayTextElement import com.intellij.codeInsight.inline.completion.suggestion.InlineCompletionSingleSuggestion -import com.intellij.codeInsight.inline.completion.suggestion.InlineCompletionSuggestion import com.intellij.openapi.application.ReadAction import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.fileEditor.FileDocumentManager @@ -24,18 +23,24 @@ class KiloCodeInlineCompletionProvider( private val handle: Int, private val project: Project, private val extensionId: String, - private val displayName: String? + private val displayName: String?, ) : InlineCompletionProvider { - + private val logger = Logger.getInstance(KiloCodeInlineCompletionProvider::class.java) private val completionService = InlineCompletionService.getInstance() - + + /** + * Custom insert handler that triggers telemetry when completions are accepted. + * Overrides the default insertHandler from InlineCompletionProvider. + */ + override val insertHandler = KiloCodeInlineCompletionInsertHandler(project) + /** * Unique identifier for this provider. * Required by InlineCompletionProvider interface. */ override val id: InlineCompletionProviderID = InlineCompletionProviderID("kilocode-inline-completion-$extensionId-$handle") - + /** * Gets inline completion suggestions using the Ghost service. * Sends full file content to ensure accurate completions. @@ -47,37 +52,37 @@ class KiloCodeInlineCompletionProvider( val positionInfo = ReadAction.compute { val editor = request.editor val document = editor.document - + // Use request.endOffset which is the correct insertion point for the completion // This is where IntelliJ expects the completion to be inserted val completionOffset = request.endOffset - + // Calculate line and character position from the completion offset val line = document.getLineNumber(completionOffset) val lineStartOffset = document.getLineStartOffset(line) val char = completionOffset - lineStartOffset - + // Get language ID from file type val virtualFile = FileDocumentManager.getInstance().getFile(document) val langId = virtualFile?.fileType?.name?.lowercase() ?: "text" - + // Also get caret position for logging/debugging val caretOffset = editor.caretModel.offset - + PositionInfo(completionOffset, line, char, langId, document, caretOffset) } - + val (offset, lineNumber, character, languageId, document, caretOffset) = positionInfo - + // Call the new service with full file content val result = completionService.getInlineCompletions( project, document, lineNumber, character, - languageId + languageId, ) - + // Convert result to InlineCompletionSingleSuggestion using the new API return when (result) { is InlineCompletionService.Result.Success -> { @@ -104,14 +109,15 @@ class KiloCodeInlineCompletionProvider( } catch (e: Exception) { // Check if this is a wrapped cancellation if (e.cause is kotlinx.coroutines.CancellationException || - e.cause is java.util.concurrent.CancellationException) { + e.cause is java.util.concurrent.CancellationException + ) { return InlineCompletionSingleSuggestion.build { } } // Real error - log appropriately return InlineCompletionSingleSuggestion.build { } } } - + /** * Determines if this provider is enabled for the given event. * Document selector matching is handled during registration. @@ -119,7 +125,7 @@ class KiloCodeInlineCompletionProvider( override fun isEnabled(event: InlineCompletionEvent): Boolean { return true } - + /** * Data class to hold position information calculated in read action */ @@ -129,6 +135,6 @@ class KiloCodeInlineCompletionProvider( val character: Int, val languageId: String, val document: com.intellij.openapi.editor.Document, - val caretOffset: Int + val caretOffset: Int, ) -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6221eec745b..c724d31f1a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4973,7 +4973,6 @@ packages: '@lancedb/lancedb@0.21.3': resolution: {integrity: sha512-hfzp498BfcCJ730fV1YGGoXVxRgE+W1n0D0KwanKlbt8bBPSQ6E6Tf8mPXc8rKdAXIRR3o5mTzMG3z3Fda+m3Q==} engines: {node: '>= 18'} - cpu: [x64, arm64] os: [darwin, linux, win32] peerDependencies: apache-arrow: '>=15.0.0 <=18.1.0' @@ -27998,7 +27997,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.57)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.19.4)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(terser@5.44.1)(tsx@4.19.4)(yaml@2.8.1) '@vitest/utils@2.0.5': dependencies: diff --git a/src/core/kilocode/agent-manager/CliPathResolver.ts b/src/core/kilocode/agent-manager/CliPathResolver.ts index e161ef40fec..eebcdf2a51b 100644 --- a/src/core/kilocode/agent-manager/CliPathResolver.ts +++ b/src/core/kilocode/agent-manager/CliPathResolver.ts @@ -4,6 +4,97 @@ import { execSync } from "node:child_process" import { fileExistsAtPath } from "../../../utils/fs" import { getLocalCliPath } from "./CliInstaller" +/** + * Case-insensitive lookup for environment variables. + * Windows environment variables can have inconsistent casing (PATH, Path, path). + */ +function getCaseInsensitive(target: NodeJS.ProcessEnv, key: string): string | undefined { + const lowercaseKey = key.toLowerCase() + const equivalentKey = Object.keys(target).find((k) => k.toLowerCase() === lowercaseKey) + return equivalentKey ? target[equivalentKey] : target[key] +} + +/** + * Check if a path exists and is a file (not a directory). + * Follows symlinks - a symlink to a file returns true, symlink to a directory returns false. + */ +async function pathExistsAsFile(filePath: string): Promise { + try { + const stat = await fs.promises.stat(filePath) + return stat.isFile() + } catch (e: unknown) { + if (e instanceof Error && "code" in e && e.code === "EACCES") { + try { + const lstat = await fs.promises.lstat(filePath) + return lstat.isFile() || lstat.isSymbolicLink() + } catch { + return false + } + } + return false + } +} + +/** + * Find an executable by name, resolving it against PATH and PATHEXT (on Windows). + */ +export async function findExecutable( + command: string, + cwd?: string, + paths?: string[], + env: NodeJS.ProcessEnv = process.env, +): Promise { + if (path.isAbsolute(command)) { + return (await pathExistsAsFile(command)) ? command : undefined + } + + if (cwd === undefined) { + cwd = process.cwd() + } + + const dir = path.dirname(command) + if (dir !== ".") { + const fullPath = path.join(cwd, command) + return (await pathExistsAsFile(fullPath)) ? fullPath : undefined + } + + const envPath = getCaseInsensitive(env, "PATH") + if (paths === undefined && typeof envPath === "string") { + paths = envPath.split(path.delimiter) + } + + if (paths === undefined || paths.length === 0) { + const fullPath = path.join(cwd, command) + return (await pathExistsAsFile(fullPath)) ? fullPath : undefined + } + + for (const pathEntry of paths) { + let fullPath: string + if (path.isAbsolute(pathEntry)) { + fullPath = path.join(pathEntry, command) + } else { + fullPath = path.join(cwd, pathEntry, command) + } + + if (process.platform === "win32") { + const pathExt = getCaseInsensitive(env, "PATHEXT") || ".COM;.EXE;.BAT;.CMD" + for (const ext of pathExt.split(";")) { + const withExtension = fullPath + ext + if (await pathExistsAsFile(withExtension)) { + return withExtension + } + } + } + + if (await pathExistsAsFile(fullPath)) { + return fullPath + } + } + + const fullPath = path.join(cwd, command) + return (await pathExistsAsFile(fullPath)) ? fullPath : undefined +} + /** * Find the kilocode CLI executable. * @@ -12,7 +103,7 @@ import { getLocalCliPath } from "./CliInstaller" * 2. Workspace-local build at /cli/dist/index.js * 3. Local installation at ~/.kilocode/cli/pkg (for immutable systems like NixOS) * 4. Login shell lookup (respects user's nvm, fnm, volta, asdf config) - * 5. Direct PATH lookup (fallback for system-wide installs) + * 5. Direct PATH lookup using findExecutable (handles PATHEXT on Windows) * 6. Common npm installation paths (last resort) * * IMPORTANT: Login shell is checked BEFORE direct PATH because: @@ -50,7 +141,6 @@ export async function findKilocodeCli(log?: (msg: string) => void): Promise void): Promise void): Promise void): string | null { - const cmd = process.platform === "win32" ? "where kilocode" : "which kilocode" - try { - const result = execSync(cmd, { encoding: "utf-8", timeout: 5000 }).split(/\r?\n/)[0]?.trim() - if (result) { - log?.(`Found CLI in PATH: ${result}`) - return result - } - } catch { - log?.("kilocode not found in direct PATH lookup") - } - return null -} - /** * Try to find kilocode by running `which` in a login shell. * This sources the user's shell profile (~/.zshrc, ~/.bashrc, etc.) * which sets up version managers like nvm, fnm, volta, asdf, etc. - * - * This is the most reliable way to find CLI installed via version managers - * because VS Code's extension host doesn't inherit the user's shell environment. */ function findViaLoginShell(log?: (msg: string) => void): string | null { if (process.platform === "win32") { - // Windows doesn't have the same shell environment concept return null } - // Detect user's shell from SHELL env var, default to bash const userShell = process.env.SHELL || "/bin/bash" const shellName = path.basename(userShell) - // Use login shell (-l) to source profile files, interactive (-i) for some shells - // that only source certain files in interactive mode const shellFlags = shellName === "zsh" ? "-l -i" : "-l" const cmd = `${userShell} ${shellFlags} -c 'which kilocode' 2>/dev/null` @@ -129,8 +196,8 @@ function findViaLoginShell(log?: (msg: string) => void): string | null { log?.(`Trying login shell lookup: ${cmd}`) const result = execSync(cmd, { encoding: "utf-8", - timeout: 10000, // 10s timeout - login shells can be slow - env: { ...process.env, HOME: process.env.HOME }, // Ensure HOME is set + timeout: 10000, + env: { ...process.env, HOME: process.env.HOME }, }) .split(/\r?\n/)[0] ?.trim() @@ -140,7 +207,6 @@ function findViaLoginShell(log?: (msg: string) => void): string | null { return result } } catch (error) { - // This is expected if CLI is not installed or shell init is slow/broken log?.(`Login shell lookup failed (this is normal if CLI not installed via version manager): ${error}`) } @@ -149,7 +215,6 @@ function findViaLoginShell(log?: (msg: string) => void): string | null { /** * Get fallback paths to check for CLI installation. - * This is used when login shell lookup fails or on Windows. */ function getNpmPaths(log?: (msg: string) => void): string[] { const home = process.env.HOME || process.env.USERPROFILE || "" @@ -164,27 +229,16 @@ function getNpmPaths(log?: (msg: string) => void): string[] { ].filter(Boolean) } - // macOS and Linux paths const paths = [ - // Local installation (for immutable systems like NixOS) getLocalCliPath(), - // macOS Homebrew (Apple Silicon) "/opt/homebrew/bin/kilocode", - // macOS Homebrew (Intel) and Linux standard "/usr/local/bin/kilocode", - // Common user-local npm prefix path.join(home, ".npm-global", "bin", "kilocode"), - // nvm: scan installed versions ...getNvmPaths(home, log), - // fnm path.join(home, ".local", "share", "fnm", "aliases", "default", "bin", "kilocode"), - // volta path.join(home, ".volta", "bin", "kilocode"), - // asdf nodejs plugin path.join(home, ".asdf", "shims", "kilocode"), - // Linux snap "/snap/bin/kilocode", - // Linux user local bin path.join(home, ".local", "bin", "kilocode"), ] @@ -193,10 +247,6 @@ function getNpmPaths(log?: (msg: string) => void): string[] { /** * Get potential nvm paths for the kilocode CLI. - * nvm installs node versions in ~/.nvm/versions/node/ - * - * Note: This is a fallback - the login shell approach (findViaLoginShell) - * is preferred because it respects the user's shell configuration. */ function getNvmPaths(home: string, log?: (msg: string) => void): string[] { const nvmDir = process.env.NVM_DIR || path.join(home, ".nvm") @@ -204,16 +254,13 @@ function getNvmPaths(home: string, log?: (msg: string) => void): string[] { const paths: string[] = [] - // Check NVM_BIN if set (current nvm version in the shell) if (process.env.NVM_BIN) { paths.push(path.join(process.env.NVM_BIN, "kilocode")) } - // Scan the nvm versions directory for installed node versions try { if (fs.existsSync(versionsDir)) { const versions = fs.readdirSync(versionsDir) - // Sort versions in reverse order to check newer versions first versions.sort().reverse() log?.(`Found ${versions.length} nvm node versions to check`) for (const version of versions) { @@ -221,7 +268,6 @@ function getNvmPaths(home: string, log?: (msg: string) => void): string[] { } } } catch (error) { - // This is normal if user doesn't have nvm installed log?.(`Could not scan nvm versions directory: ${error}`) } diff --git a/src/core/kilocode/agent-manager/CliProcessHandler.ts b/src/core/kilocode/agent-manager/CliProcessHandler.ts index 003274f1c5c..dbf2afdc5a0 100644 --- a/src/core/kilocode/agent-manager/CliProcessHandler.ts +++ b/src/core/kilocode/agent-manager/CliProcessHandler.ts @@ -1,4 +1,5 @@ import { spawn, ChildProcess } from "node:child_process" +import * as path from "node:path" import { CliOutputParser, type StreamEvent, @@ -37,6 +38,7 @@ interface PendingProcessInfo { gitUrl?: string stderrBuffer: string[] // Capture stderr for error detection timeoutId?: NodeJS.Timeout // Timer for auto-failing stuck pending sessions + cliPath?: string // CLI path for error telemetry provisionalSessionId?: string // Temporary session ID created when api_req_started arrives (before session_created) } @@ -211,6 +213,7 @@ export class CliProcessHandler { gitUrl: options?.gitUrl, stderrBuffer: [], timeoutId: setTimeout(() => this.handlePendingTimeout(), PENDING_SESSION_TIMEOUT_MS), + cliPath, } } @@ -666,10 +669,24 @@ export class CliProcessHandler { private handleProcessError(proc: ChildProcess, error: Error): void { if (this.pendingProcess && this.pendingProcess.process === proc) { + const cliPath = this.pendingProcess.cliPath this.clearPendingTimeout() this.registry.clearPendingSession() this.callbacks.onPendingSessionChanged(null) this.pendingProcess = null + + // Capture spawn error telemetry with context for debugging + const { platform, shell } = getPlatformDiagnostics() + const cliPathExtension = cliPath ? path.extname(cliPath).slice(1).toLowerCase() || undefined : undefined + captureAgentManagerLoginIssue({ + issueType: "cli_spawn_error", + platform, + shell, + errorMessage: error.message, + cliPath, + cliPathExtension, + }) + this.callbacks.onStartSessionFailed({ type: "spawn_error", message: error.message, diff --git a/src/core/kilocode/agent-manager/__tests__/AgentManagerProvider.spec.ts b/src/core/kilocode/agent-manager/__tests__/AgentManagerProvider.spec.ts index ea7778f290a..bae9b2dfcf1 100644 --- a/src/core/kilocode/agent-manager/__tests__/AgentManagerProvider.spec.ts +++ b/src/core/kilocode/agent-manager/__tests__/AgentManagerProvider.spec.ts @@ -6,6 +6,7 @@ const MOCK_CLI_PATH = "/mock/path/to/kilocode" // Mock the local telemetry module vi.mock("../telemetry", () => ({ + getPlatformDiagnostics: vi.fn(() => ({ platform: "darwin", shell: "bash" })), captureAgentManagerOpened: vi.fn(), captureAgentManagerSessionStarted: vi.fn(), captureAgentManagerSessionCompleted: vi.fn(), @@ -45,8 +46,15 @@ describe("AgentManagerProvider CLI spawning", () => { ExtensionMode: { Development: 1, Production: 2, Test: 3 }, })) + // Mock CliInstaller so getLocalCliPath returns our mock path + vi.doMock("../CliInstaller", () => ({ + getLocalCliPath: () => MOCK_CLI_PATH, + })) + + // Mock fileExistsAtPath to return true only for MOCK_CLI_PATH + // This ensures findKilocodeCli finds the CLI via local path check (works on all platforms) vi.doMock("../../../../utils/fs", () => ({ - fileExistsAtPath: vi.fn().mockResolvedValue(false), + fileExistsAtPath: vi.fn().mockImplementation((p: string) => Promise.resolve(p === MOCK_CLI_PATH)), })) // Mock getRemoteUrl for gitUrl support @@ -89,70 +97,6 @@ describe("AgentManagerProvider CLI spawning", () => { expect(options?.shell).not.toBe(true) }) - it("spawns with shell: true on Windows when CLI path ends with .cmd", async () => { - // Reset modules to set up Windows-specific mock - vi.resetModules() - - const mockWorkspaceFolder = { uri: { fsPath: "/tmp/workspace" } } - const mockProvider = { - getState: vi.fn().mockResolvedValue({ apiConfiguration: { apiProvider: "kilocode" } }), - } - - vi.doMock("vscode", () => ({ - workspace: { workspaceFolders: [mockWorkspaceFolder] }, - window: { showErrorMessage: vi.fn(), showWarningMessage: vi.fn(), ViewColumn: { One: 1 } }, - env: { openExternal: vi.fn() }, - Uri: { parse: vi.fn(), joinPath: vi.fn() }, - ViewColumn: { One: 1 }, - ExtensionMode: { Development: 1, Production: 2, Test: 3 }, - })) - - vi.doMock("../../../../utils/fs", () => ({ - fileExistsAtPath: vi.fn().mockResolvedValue(false), - })) - - vi.doMock("../../../../services/code-index/managed/git-utils", () => ({ - getRemoteUrl: vi.fn().mockResolvedValue(undefined), - })) - - class TestProc extends EventEmitter { - stdout = new EventEmitter() - stderr = new EventEmitter() - kill = vi.fn() - pid = 1234 - } - - const spawnMock = vi.fn(() => new TestProc()) - // Return a .cmd path to simulate Windows local CLI installation - const execSyncMock = vi.fn(() => "C:\\Users\\test\\.kilocode\\cli\\pkg\\node_modules\\.bin\\kilocode.cmd") - - vi.doMock("node:child_process", () => ({ - spawn: spawnMock, - execSync: execSyncMock, - })) - - // Mock process.platform to be win32 - const originalPlatform = process.platform - Object.defineProperty(process, "platform", { value: "win32", writable: true }) - - try { - const module = await import("../AgentManagerProvider") - const windowsProvider = new module.AgentManagerProvider(mockContext, mockOutputChannel, mockProvider as any) - - await (windowsProvider as any).startAgentSession("test windows cmd") - - expect(spawnMock).toHaveBeenCalledTimes(1) - const [cmd, , options] = spawnMock.mock.calls[0] as unknown as [string, string[], Record] - expect(cmd.toLowerCase()).toContain(".cmd") - expect(options?.shell).toBe(true) - - windowsProvider.dispose() - } finally { - // Restore original platform - Object.defineProperty(process, "platform", { value: originalPlatform, writable: true }) - } - }) - it("creates pending session and waits for session_created event", async () => { await (provider as any).startAgentSession("test pending") @@ -557,8 +501,13 @@ describe("AgentManagerProvider gitUrl filtering", () => { ExtensionMode: { Development: 1, Production: 2, Test: 3 }, })) + // Mock CliInstaller so getLocalCliPath returns our mock path + vi.doMock("../CliInstaller", () => ({ + getLocalCliPath: () => MOCK_CLI_PATH, + })) + vi.doMock("../../../../utils/fs", () => ({ - fileExistsAtPath: vi.fn().mockResolvedValue(false), + fileExistsAtPath: vi.fn().mockImplementation((p: string) => Promise.resolve(p === MOCK_CLI_PATH)), })) mockGetRemoteUrl = vi.fn().mockResolvedValue("https://github.com/org/repo.git") @@ -794,8 +743,13 @@ describe("AgentManagerProvider telemetry", () => { ExtensionMode: { Development: 1, Production: 2, Test: 3 }, })) + // Mock CliInstaller so getLocalCliPath returns our mock path + vi.doMock("../CliInstaller", () => ({ + getLocalCliPath: () => MOCK_CLI_PATH, + })) + vi.doMock("../../../../utils/fs", () => ({ - fileExistsAtPath: vi.fn().mockResolvedValue(false), + fileExistsAtPath: vi.fn().mockImplementation((p: string) => Promise.resolve(p === MOCK_CLI_PATH)), })) vi.doMock("../../../../services/code-index/managed/git-utils", () => ({ diff --git a/src/core/kilocode/agent-manager/__tests__/CliPathResolver.spec.ts b/src/core/kilocode/agent-manager/__tests__/CliPathResolver.spec.ts index 663a65cb5c3..7d420fdf061 100644 --- a/src/core/kilocode/agent-manager/__tests__/CliPathResolver.spec.ts +++ b/src/core/kilocode/agent-manager/__tests__/CliPathResolver.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi, beforeEach } from "vitest" +import * as path from "node:path" const isWindows = process.platform === "win32" @@ -10,40 +11,45 @@ describe("findKilocodeCli", () => { const loginShellTests = isWindows ? it.skip : it loginShellTests("finds CLI via login shell and returns trimmed result", async () => { - // Login shell is tried first, so mock it to succeed const execSyncMock = vi.fn().mockReturnValue("/Users/test/.nvm/versions/node/v20/bin/kilocode\n") vi.doMock("node:child_process", () => ({ execSync: execSyncMock })) vi.doMock("../../../../utils/fs", () => ({ fileExistsAtPath: vi.fn().mockResolvedValue(false) })) + vi.doMock("node:fs", () => ({ + existsSync: vi.fn().mockReturnValue(false), + promises: { stat: vi.fn().mockRejectedValue(new Error("ENOENT")) }, + })) const { findKilocodeCli } = await import("../CliPathResolver") const result = await findKilocodeCli() expect(result).toBe("/Users/test/.nvm/versions/node/v20/bin/kilocode") - // First call should be login shell (on non-Windows) expect(execSyncMock).toHaveBeenCalledWith( expect.stringContaining("which kilocode"), expect.objectContaining({ encoding: "utf-8" }), ) }) - loginShellTests("falls back to direct PATH when login shell fails", async () => { - let callCount = 0 - const execSyncMock = vi.fn().mockImplementation((cmd: string) => { - callCount++ - // First call (login shell) fails, second call (direct PATH) succeeds - if (callCount === 1) { - throw new Error("login shell failed") + loginShellTests("falls back to findExecutable when login shell fails", async () => { + const execSyncMock = vi.fn().mockImplementation(() => { + throw new Error("login shell failed") + }) + const statMock = vi.fn().mockImplementation((filePath: string) => { + if (filePath === "/usr/local/bin/kilocode") { + return Promise.resolve({ isFile: () => true }) } - return "/usr/local/bin/kilocode\n" + return Promise.reject(new Error("ENOENT")) }) vi.doMock("node:child_process", () => ({ execSync: execSyncMock })) vi.doMock("../../../../utils/fs", () => ({ fileExistsAtPath: vi.fn().mockResolvedValue(false) })) + vi.doMock("node:fs", () => ({ + existsSync: vi.fn().mockReturnValue(false), + promises: { stat: statMock }, + })) const { findKilocodeCli } = await import("../CliPathResolver") const result = await findKilocodeCli() expect(result).toBe("/usr/local/bin/kilocode") - expect(execSyncMock).toHaveBeenCalledTimes(2) }) it("falls back to npm paths when all PATH lookups fail", async () => { @@ -51,11 +57,14 @@ describe("findKilocodeCli", () => { throw new Error("not found") }) const fileExistsMock = vi.fn().mockImplementation((path: string) => { - // Return true for first path checked to verify fallback works return Promise.resolve(path.includes("kilocode")) }) vi.doMock("node:child_process", () => ({ execSync: execSyncMock })) vi.doMock("../../../../utils/fs", () => ({ fileExistsAtPath: fileExistsMock })) + vi.doMock("node:fs", () => ({ + existsSync: vi.fn().mockReturnValue(false), + promises: { stat: vi.fn().mockRejectedValue(new Error("ENOENT")) }, + })) const { findKilocodeCli } = await import("../CliPathResolver") const result = await findKilocodeCli() @@ -71,6 +80,10 @@ describe("findKilocodeCli", () => { }), })) vi.doMock("../../../../utils/fs", () => ({ fileExistsAtPath: vi.fn().mockResolvedValue(false) })) + vi.doMock("node:fs", () => ({ + existsSync: vi.fn().mockReturnValue(false), + promises: { stat: vi.fn().mockRejectedValue(new Error("ENOENT")) }, + })) const { findKilocodeCli } = await import("../CliPathResolver") const logMock = vi.fn() @@ -80,18 +93,176 @@ describe("findKilocodeCli", () => { expect(logMock).toHaveBeenCalledWith("kilocode CLI not found") }) - it("logs when kilocode not in direct PATH", async () => { + it("logs when kilocode not in PATH", async () => { vi.doMock("node:child_process", () => ({ execSync: vi.fn().mockImplementation(() => { throw new Error("not found") }), })) vi.doMock("../../../../utils/fs", () => ({ fileExistsAtPath: vi.fn().mockResolvedValue(false) })) + vi.doMock("node:fs", () => ({ + existsSync: vi.fn().mockReturnValue(false), + promises: { stat: vi.fn().mockRejectedValue(new Error("ENOENT")) }, + })) const { findKilocodeCli } = await import("../CliPathResolver") const logMock = vi.fn() await findKilocodeCli(logMock) - expect(logMock).toHaveBeenCalledWith("kilocode not found in direct PATH lookup") + expect(logMock).toHaveBeenCalledWith("kilocode not found in PATH lookup") + }) +}) + +describe("findExecutable", () => { + beforeEach(() => { + vi.resetModules() + }) + + // These tests use Unix-style paths which are not absolute on Windows + // Skip on Windows - the Windows-specific behavior is tested below + const unixOnlyTest = isWindows ? it.skip : it + + unixOnlyTest("returns absolute path if file exists", async () => { + const statMock = vi.fn().mockResolvedValue({ isFile: () => true }) + vi.doMock("node:fs", () => ({ + promises: { stat: statMock }, + })) + + const { findExecutable } = await import("../CliPathResolver") + const result = await findExecutable("/usr/bin/kilocode") + + expect(result).toBe("/usr/bin/kilocode") + }) + + unixOnlyTest("returns undefined for absolute path if file does not exist", async () => { + const statMock = vi.fn().mockRejectedValue(new Error("ENOENT")) + vi.doMock("node:fs", () => ({ + promises: { stat: statMock }, + })) + + const { findExecutable } = await import("../CliPathResolver") + const result = await findExecutable("/usr/bin/nonexistent") + + expect(result).toBeUndefined() + }) + + unixOnlyTest("searches PATH entries for command", async () => { + const statMock = vi.fn().mockImplementation((filePath: string) => { + if (filePath === "/custom/bin/myapp") { + return Promise.resolve({ isFile: () => true }) + } + return Promise.reject(new Error("ENOENT")) + }) + vi.doMock("node:fs", () => ({ + promises: { stat: statMock }, + })) + + const { findExecutable } = await import("../CliPathResolver") + const result = await findExecutable("myapp", "/home/user", ["/usr/bin", "/custom/bin"]) + + expect(result).toBe("/custom/bin/myapp") + }) + + // Windows PATHEXT tests - run only on Windows CI + // We don't simulate Windows on other platforms - let actual Windows CI test it + describe("Windows PATHEXT handling", () => { + const windowsOnlyTest = isWindows ? it : it.skip + const testDir = "C:\\npm" + const testCwd = "C:\\home\\test" + + const createFsMock = (matchPaths: string[]) => ({ + existsSync: vi.fn().mockReturnValue(false), + promises: { + stat: vi.fn().mockImplementation((filePath: string) => { + if (matchPaths.some((p) => filePath === p)) { + return Promise.resolve({ isFile: () => true }) + } + return Promise.reject(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + }), + lstat: vi.fn().mockImplementation((filePath: string) => { + if (matchPaths.some((p) => filePath === p)) { + return Promise.resolve({ isFile: () => true, isSymbolicLink: () => false }) + } + return Promise.reject(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + }), + }, + }) + + windowsOnlyTest("finds .CMD file via PATHEXT", async () => { + const expectedPath = path.join(testDir, "kilocode") + ".CMD" + vi.doMock("node:fs", () => createFsMock([expectedPath])) + + const { findExecutable } = await import("../CliPathResolver") + const result = await findExecutable("kilocode", testCwd, [testDir], { + PATH: testDir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }) + + expect(result).toBe(expectedPath) + }) + + windowsOnlyTest("uses default PATHEXT when not in env", async () => { + const expectedPath = path.join(testDir, "kilocode") + ".CMD" + vi.doMock("node:fs", () => createFsMock([expectedPath])) + + const { findExecutable } = await import("../CliPathResolver") + const result = await findExecutable("kilocode", testCwd, [testDir], { + PATH: testDir, + }) + + expect(result).toBe(expectedPath) + }) + + windowsOnlyTest("handles case-insensitive env var lookup", async () => { + const expectedPath = path.join(testDir, "kilocode") + ".EXE" + vi.doMock("node:fs", () => createFsMock([expectedPath])) + + const { findExecutable } = await import("../CliPathResolver") + const result = await findExecutable("kilocode", testCwd, undefined, { + Path: testDir, // lowercase 'ath' - Windows env vars are case-insensitive + PathExt: ".COM;.EXE;.BAT;.CMD", + }) + + expect(result).toBe(expectedPath) + }) + + windowsOnlyTest("returns first matching PATHEXT extension", async () => { + const comPath = path.join(testDir, "kilocode") + ".COM" + const exePath = path.join(testDir, "kilocode") + ".EXE" + vi.doMock("node:fs", () => createFsMock([comPath, exePath])) + + const { findExecutable } = await import("../CliPathResolver") + const result = await findExecutable("kilocode", testCwd, [testDir], { + PATH: testDir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }) + + expect(result).toBe(comPath) + }) + }) + + // Non-Windows test - skipped on Windows since we can't simulate other platforms + const nonWindowsTest = isWindows ? it.skip : it + + nonWindowsTest("does not use PATHEXT on non-Windows platforms", async () => { + const statMock = vi.fn().mockImplementation((filePath: string) => { + if (filePath === "/usr/bin/kilocode") { + return Promise.resolve({ isFile: () => true }) + } + return Promise.reject(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + }) + vi.doMock("node:fs", () => ({ + existsSync: vi.fn().mockReturnValue(false), + promises: { + stat: statMock, + lstat: vi.fn().mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })), + }, + })) + + const { findExecutable } = await import("../CliPathResolver") + const result = await findExecutable("kilocode", "/home/user", ["/usr/bin"]) + + expect(result).toBe("/usr/bin/kilocode") + expect(statMock).not.toHaveBeenCalledWith(expect.stringContaining(".CMD")) }) }) diff --git a/src/core/kilocode/agent-manager/telemetry.ts b/src/core/kilocode/agent-manager/telemetry.ts index 3d53774ab1a..322ce237cf7 100644 --- a/src/core/kilocode/agent-manager/telemetry.ts +++ b/src/core/kilocode/agent-manager/telemetry.ts @@ -29,6 +29,10 @@ export interface AgentManagerLoginIssueProperties { httpStatusCode?: number platform?: "darwin" | "win32" | "linux" | "other" shell?: string + // Spawn error details for debugging Windows issues + errorMessage?: string + cliPath?: string + cliPathExtension?: string } export function captureAgentManagerOpened(): void { diff --git a/src/services/code-index/managed/ignore-list.ts b/src/services/code-index/managed/ignore-list.ts index 0158ab476c5..6eac68497b1 100644 --- a/src/services/code-index/managed/ignore-list.ts +++ b/src/services/code-index/managed/ignore-list.ts @@ -41,7 +41,8 @@ export function shouldIgnoreFile(relativeFilePath: string): boolean { ext === ".min.js" || ext === ".min.css" || ext === ".bundle.js" || - ext === ".map" + ext === ".map" || + fileName.match(/\.dist\..+$/) ) { return true } diff --git a/src/services/ghost/GhostServiceManager.ts b/src/services/ghost/GhostServiceManager.ts index 3aa547399d8..decb07d20ef 100644 --- a/src/services/ghost/GhostServiceManager.ts +++ b/src/services/ghost/GhostServiceManager.ts @@ -49,14 +49,13 @@ export class GhostServiceManager { // Register the providers this.codeActionProvider = new GhostCodeActionProvider() - const { kiloCodeWrapperJetbrains } = getKiloCodeWrapperProperties() this.inlineCompletionProvider = new GhostInlineCompletionProvider( this.context, this.model, this.updateCostTracking.bind(this), () => this.settings, this.cline, - !kiloCodeWrapperJetbrains ? new AutocompleteTelemetry() : null, + new AutocompleteTelemetry(), ) void this.load()