diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 507ba3ee00..365915f048 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -227,6 +227,19 @@ to use if you are uncertain which method to use for debugging. Option 2 is especially useful when you need to set a breakpoint very early in the Agent startup. +### Using VS Code to debug the Agent + +The setup described here also works with VS Code as your debugger and/or +launcher. Both configurations work with IntelliJ and VS Code on the Cody +side, and the VS Code Cody extension has both configurations available +in its default run configurations. + +VS Code is usually the better choice for a debugger, because the JetBrains +TypeScript plugin gets very confused over our TypeScript code base, and +cannot search for files or symbols. + +Sometimes using JetBrains is more convenient, so we describe both options. + ## How to set up Run Configurations Run configurations are basically IDEA's launcher scripts. You will need @@ -407,3 +420,22 @@ see the configuration dropdown at the top. - Workaround is to exit the target gracefully by quitting each time, using the menus or hotkeys, rather than force-stopping it. +# Integration Testing + +Run the integration tests at the command line with: + +``` +./gradlew integrationTest +``` + +If you pass in an access token, it will use the default production LLM +rather than a mock LLM, which can be useful when updating the test if +the protocol changes. + +``` +CODY_INTEGRATION_TEST_TOKEN=sgp_asdfasdfasdfasdfasdfasdfasdf +``` + +You can run and debug the integration tests, including the Agent node +process, with the instructions above by making new run configurations +for the test. diff --git a/build.gradle.kts b/build.gradle.kts index 3f4300ce6d..fd9765f97e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,9 +8,10 @@ import java.nio.file.Paths import java.nio.file.SimpleFileVisitor import java.nio.file.StandardCopyOption import java.nio.file.attribute.BasicFileAttributes -import java.util.EnumSet +import java.util.* import java.util.jar.JarFile import java.util.zip.ZipFile +import kotlin.script.experimental.jvm.util.hasParentNamed import org.jetbrains.changelog.markdownToHTML import org.jetbrains.intellij.tasks.RunPluginVerifierTask.FailureLevel import org.jetbrains.kotlin.gradle.tasks.KotlinCompile @@ -48,6 +49,7 @@ val skippedFailureLevels = plugins { id("java") + id("jvm-test-suite") // Dependencies are locked at this version to work with JDK 11 on CI. id("org.jetbrains.kotlin.jvm") version "1.9.22" id("org.jetbrains.intellij") version "1.17.3" @@ -55,21 +57,17 @@ plugins { id("com.diffplug.spotless") version "6.25.0" } +val kotlinVersion: String by project +val platformVersion: String by project +val javaVersion: String by project + group = properties("pluginGroup") version = properties("pluginVersion") -repositories { mavenCentral() } - -intellij { - pluginName.set(properties("pluginName")) - version.set(properties("platformVersion")) - type.set(properties("platformType")) - - // Plugin Dependencies. Uses `platformPlugins` property from the gradle.properties file. - plugins.set(properties("platformPlugins").split(',').map(String::trim).filter(String::isNotEmpty)) - - updateSinceUntilBuild.set(false) +repositories { + maven { url = uri("https://www.jetbrains.com/intellij-repository/releases") } + mavenCentral() } dependencies { @@ -81,6 +79,10 @@ dependencies { implementation("org.eclipse.lsp4j:org.eclipse.lsp4j.jsonrpc:0.21.0") implementation("com.googlecode.java-diff-utils:diffutils:1.3.0") testImplementation("org.awaitility:awaitility-kotlin:4.2.0") + testImplementation("org.junit.jupiter:junit-jupiter:5.7.0") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit:1.9.22") + implementation("org.mockito:mockito-all:1.10.19") + testImplementation("org.mockito.kotlin:mockito-kotlin:5.3.1") } spotless { @@ -98,9 +100,21 @@ spotless { ktfmt() trimTrailingWhitespace() target("src/**/*.kt") + toggleOffOn() } } +intellij { + pluginName.set(properties("pluginName")) + version.set(platformVersion) + type.set(properties("platformType")) + + // Plugin Dependencies. Uses `platformPlugins` property from the gradle.properties file. + plugins.set(properties("platformPlugins").split(',').map(String::trim).filter(String::isNotEmpty)) + + updateSinceUntilBuild.set(false) +} + java { toolchain { // Always compile the codebase with Java 11 regardless of what Java @@ -110,6 +124,8 @@ java { } } +tasks.named("classpathIndexCleanup") { dependsOn("compileIntegrationTestKotlin") } + fun download(url: String, output: File) { if (output.exists()) { println("Cached $output") @@ -182,7 +198,7 @@ fun unzip(input: File, output: File, excludeMatcher: PathMatcher? = null) { } } -val githubArchiveCache = +val githubArchiveCache: File = Paths.get(System.getProperty("user.home"), ".sourcegraph", "caches", "jetbrains").toFile() tasks { @@ -388,6 +404,7 @@ tasks { jvmArgs("-Djdk.module.illegalAccess.silent=true") agentProperties.forEach { (key, value) -> systemProperty(key, value) } + environment("CODY_JETBRAINS_FEATURES", "cody.feature.inline-edits=true") val platformRuntimeVersion = project.findProperty("platformRuntimeVersion") if (platformRuntimeVersion != null) { @@ -438,6 +455,137 @@ tasks { } } + // TODO(stevey): Everything below here needs work. + test { dependsOn(project.tasks.getByPath("buildCody")) } + + configurations { + create("integrationTestImplementation") { extendsFrom(configurations.testImplementation.get()) } + create("integrationTestRuntimeClasspath") { extendsFrom(configurations.testRuntimeOnly.get()) } + } + + sourceSets { + create("integrationTest") { + kotlin.srcDir("src/integrationTest/kotlin") + compileClasspath += main.get().output + runtimeClasspath += main.get().output + } + } + + val integrationTestSystemProps = + mapOf( + "cody-agent.trace-path" to "$buildDir/sourcegraph/cody-agent-trace.json", + "cody-agent.directory" to buildCodyDir.parent, + "sourcegraph.verbose-logging" to "true", + "cody.autocomplete.enableFormatting" to + (project.property("cody.autocomplete.enableFormatting") as String? ?: "true"), + "cody.integration.testing" to "true", + // For now, should be used by all tests + "idea.test.execution.policy" to "com.sourcegraph.cody.test.NonEdtIdeaTestExecutionPolicy", + "test.resources.dir" to project.file("src/integrationTest/resources").absolutePath) + + val integrationTestEnvVars = + mapOf( + "CODY_JETBRAINS_FEATURES" to "cody.feature.inline-edits=true", + "CODY_RECORDING_MODE" to "replay", + "CODY_RECORDING_DIRECTORY" to "recordings", + "CODY_RECORD_IF_MISSING" to "false") // Polly needs this to record at all. + + // Common configuration for integration tests. + // TODO: This doesn't actually work. + fun Test.sharedIntegrationTestConfig() { + group = "verification" + testClassesDirs = sourceSets["integrationTest"].output.classesDirs + classpath = sourceSets["integrationTest"].runtimeClasspath + + // TODO: Refactor to share these with runIde. + systemProperty("cody-agent.trace-path", "$buildDir/sourcegraph/cody-agent-trace.json") + systemProperty("cody-agent.directory", buildCodyDir.parent) + systemProperty("sourcegraph.verbose-logging", "true") + systemProperty( + "cody.autocomplete.enableFormatting", + project.property("cody.autocomplete.enableFormatting") as String? ?: "true") + + include { it.file.hasParentNamed("integrationTest") } + + integrationTestSystemProps.forEach { (k, v) -> systemProperty(k, v) } + integrationTestEnvVars.forEach { (k, v) -> environment(k, v) } + + useJUnit() + + dependsOn("buildCody") + } + + // Make sure to set CODY_INTEGRATION_TEST_TOKEN env var when using this task. + register("integrationTest") { + description = "Runs the integration tests." + group = "verification" + testClassesDirs = sourceSets["integrationTest"].output.classesDirs + classpath = sourceSets["integrationTest"].runtimeClasspath + + val resourcesPath = project.file("src/integrationTest/resources").absolutePath + systemProperty("test.resources.dir", resourcesPath) + + // TODO: Refactor to share these with runIde. + systemProperty("cody-agent.trace-path", "$buildDir/sourcegraph/cody-agent-trace.json") + systemProperty("cody-agent.directory", buildCodyDir.parent) + systemProperty("sourcegraph.verbose-logging", "true") + systemProperty( + "cody.autocomplete.enableFormatting", + project.property("cody.autocomplete.enableFormatting") as String? ?: "true") + + include { it.file.hasParentNamed("integrationTest") } + + useJUnit() + + systemProperty("cody.integration.testing", "true") + systemProperty( + "idea.test.execution.policy", // For now, should be used by all tests + "com.sourcegraph.cody.test.NonEdtIdeaTestExecutionPolicy") + + environment("CODY_JETBRAINS_FEATURES", "cody.feature.inline-edits=true") + environment("CODY_RECORDING_MODE", "replay") + environment("CODY_RECORDING_DIRECTORY", "recordings") + environment("CODY_RECORD_IF_MISSING", "false") // Polly needs this to record at all. + + dependsOn("buildCody") + } + + // Make sure to set CODY_INTEGRATION_TEST_TOKEN when using this task. + register("passthroughIntegrationTest") { + description = "Runs the integration tests, passing everything through to the LLM." + sharedIntegrationTestConfig() + environment("CODY_RECORDING_MODE", "passthrough") + } + + // Make sure to set CODY_INTEGRATION_TEST_TOKEN when using this task. + register("recordingIntegrationTest") { + description = "Runs the integration tests and records the responses." + sharedIntegrationTestConfig() + + environment("CODY_RECORDING_MODE", "record") + environment("CODY_RECORDING_NAME", "integration-tests") + environment("CODY_RECORD_IF_MISSING", "true") // Polly needs this to record at all. + } + + named("processIntegrationTestResources") { + from(sourceSets["integrationTest"].resources) + into("$buildDir/resources/integrationTest") + exclude("**/.idea/**") + exclude("**/*.xml") + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + } + + withType { systemProperty("idea.test.src.dir", "$buildDir/resources/integrationTest") } + + named("integrationTest") { + dependsOn("processIntegrationTestResources") + // sharedIntegrationTestConfig() + } + + named("classpathIndexCleanup") { dependsOn("processIntegrationTestResources") } + + named("check") { dependsOn("integrationTest") } + test { agentProperties.forEach { (key, value) -> systemProperty(key, value) } diff --git a/gradle.properties b/gradle.properties index 16475e4aec..baa613c648 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,6 +18,7 @@ platformPlugins=Git4Idea,PerforceDirectPlugin,java javaVersion=11 # Gradle Releases -> https://github.com/gradle/gradle/releases gradleVersion=8.1.1 +kotlinVersion=1.9.22 // Also change this in build.gradle.kts plugins section # Opt-out flag for bundling Kotlin standard library. # See https://plugins.jetbrains.com/docs/intellij/kotlin.html#kotlin-standard-library for details. # suppress inspection "UnusedProperty" diff --git a/settings.gradle.kts b/settings.gradle.kts index ee9352d8be..3722f0dcef 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,9 +1,5 @@ rootProject.name = "Sourcegraph" -include(":jetbrains-shared") - -project(":jetbrains-shared").projectDir = file("../jetbrains-shared") - val isCiServer = System.getenv().containsKey("CI") buildCache { local { isEnabled = !isCiServer } } diff --git a/src/integrationTest/kotlin/com/sourcegraph/cody/edit/DocumentCodeTest.kt b/src/integrationTest/kotlin/com/sourcegraph/cody/edit/DocumentCodeTest.kt new file mode 100644 index 0000000000..4b673e6823 --- /dev/null +++ b/src/integrationTest/kotlin/com/sourcegraph/cody/edit/DocumentCodeTest.kt @@ -0,0 +1,323 @@ +package com.sourcegraph.cody.edit + +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.EditorTestUtil +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.testFramework.runInEdtAndWait +import com.intellij.util.messages.Topic +import com.sourcegraph.cody.edit.sessions.FixupSession +import com.sourcegraph.cody.edit.widget.LensAction +import com.sourcegraph.cody.edit.widget.LensGroupFactory +import com.sourcegraph.cody.edit.widget.LensLabel +import com.sourcegraph.cody.edit.widget.LensSpinner +import com.sourcegraph.config.ConfigUtil +import org.mockito.Mockito.mock +import java.io.File +import java.nio.file.Paths +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import java.util.regex.Pattern + +class DocumentCodeTest : BasePlatformTestCase() { + private val logger = Logger.getInstance(DocumentCodeTest::class.java) + + private lateinit var testDataPath: File + + override fun setUp() { + super.setUp() + configureFixture() + } + + override fun tearDown() { + try { + FixupService.getInstance(myFixture.project).getActiveSession()?.apply { + try { + dispose() + } catch (x: Exception) { + logger.warn("Error shutting down session", x) + } + } + // Notify the Agent that all documents have been closed. + val fileEditorManager = FileEditorManager.getInstance(myFixture.project) + fileEditorManager.openFiles.forEach { + // TODO: Check that this shows up in the trace.json file (textDocument/didClose). + fileEditorManager.closeFile(it) + } + try { + // TODO: This seemed to kill one of the tests. + // testDataPath.deleteRecursively() + } catch (x: Exception) { + logger.warn("Error deleting test data", x) + } + } finally { + super.tearDown() + } + } + + fun testGetsFoldingRanges() { + val foldingRangeFuture = subscribeToTopic(CodyInlineEditActionNotifier.TOPIC_FOLDING_RANGES) + executeDocumentCodeAction() + runInEdtAndWait { + val rangeContext = + try { + foldingRangeFuture.get() + } catch (t: TimeoutException) { + fail("Timed out waiting for folding ranges") + null + } + assertNotNull("Computed selection range should be non-null", rangeContext) + // Ensure we were able to get the selection range. + val selection = rangeContext!!.selectionRange + assertNotNull("Selection should have been set", selection) + // We set the selection range to whatever the protocol returns. + // If a 0-width selection turns out to be reasonable we can adjust or remove this test. + assertFalse("Selection range should not be zero-width", selection!!.start == selection.end) + // A more robust check is to see if the selection "range" is just the caret position. + // If so, then our fallback range somehow made the round trip, which is bad. The lenses will + // go in the wrong places, etc. + val document = myFixture.editor.document + val startOffset = selection.start.toOffset(document) + val endOffset = selection.end.toOffset(document) + val caret = myFixture.editor.caretModel.primaryCaret.offset + assertFalse( + "Selection range should not equal the caret position", + startOffset == caret && endOffset == caret) + } + } + + fun skip_testGetsWorkingGroupLens() { + val future = subscribeToTopic(CodyInlineEditActionNotifier.TOPIC_DISPLAY_WORKING_GROUP) + executeDocumentCodeAction() + + // Wait for the working group. + val context = future.get() + assertNotNull("Timed out waiting for working group lens", context) + + // The inlay should be up. + assertTrue( + "Lens group inlay should be displayed", myFixture.editor.inlayModel.hasBlockElements()) + + // Lens group should match the expected structure. + val lenses = context!!.session.lensGroup + assertNotNull("Lens group should be displayed", lenses) + + val widgets = lenses!!.widgets + assertEquals("Lens group should have 6 widgets", 6, widgets.size) + assertTrue("Zeroth lens should be a spinner", widgets[0] is LensSpinner) + assertTrue("First lens is space separator label", (widgets[1] as LensLabel).text == " ") + assertTrue("Second lens is working label", (widgets[2] as LensLabel).text.contains("working")) + assertTrue( + "Third lens is separator label", + (widgets[3] as LensLabel).text == LensGroupFactory.SEPARATOR) + assertTrue("Fourth lens should be an action", widgets[4] is LensAction) + assertTrue( + "Fifth lens should be a label with a hotkey", + (widgets[5] as LensLabel).text.matches(Regex(" \\(.+\\)"))) + } + + private fun awaitAcceptLensGroup(): CodyInlineEditActionNotifier.Context { + val future = subscribeToTopic(CodyInlineEditActionNotifier.TOPIC_DISPLAY_ACCEPT_GROUP) + executeDocumentCodeAction() // awaits sending command to Agent + val context = future.get() // awaits the Accept group appearing + assertNotNull("Timed out waiting for Accept group lens", context) + val editor = myFixture.editor + assertTrue("Lens group inlay should be displayed", editor.inlayModel.hasBlockElements()) + return context!! + } + + fun skip_testShowsAcceptLens() { + val context = awaitAcceptLensGroup() + + // Lens group should match the expected structure. + val lenses = context.session.lensGroup + assertNotNull("Lens group should be displayed", lenses) + + val widgets = lenses!!.widgets + // There are 13 widgets as of the time of writing, but the UX could change, so check robustly. + assertTrue("Lens group should have at least 4 widgets", widgets.size >= 4) + assertNotNull( + "Lens group should contain Accept action", + widgets.find { widget -> + widget is LensAction && widget.actionId == FixupSession.ACTION_ACCEPT + }) + assertNotNull( + "Lens group should contain Show Diff action", + widgets.find { widget -> + widget is LensAction && widget.actionId == FixupSession.ACTION_DIFF + }) + assertNotNull( + "Lens group should contain Show Undo action", + widgets.find { widget -> + widget is LensAction && widget.actionId == FixupSession.ACTION_UNDO + }) + assertNotNull( + "Lens group should contain Show Retry action", + widgets.find { widget -> + widget is LensAction && widget.actionId == FixupSession.ACTION_RETRY + }) + + // Make sure a doc comment was inserted. + assertTrue(hasJavadocComment(myFixture.editor.document.text)) + + // This avoids an error saying the spinner hasn't shut down, at the end of the test. + runInEdtAndWait { Disposer.dispose(lenses) } + } + + fun skip_testAccept() { + val project = myFixture.project!! + assertNull(FixupService.getInstance(project).getActiveSession()) + + awaitAcceptLensGroup() + assertTrue(myFixture.editor.inlayModel.hasBlockElements()) + assertNotNull(FixupService.getInstance(project).getActiveSession()) + + val future = subscribeToTopic(CodyInlineEditActionNotifier.TOPIC_PERFORM_ACCEPT) + triggerAction(FixupSession.ACTION_ACCEPT) + + val context = future.get() + assertNotNull("Timed out waiting for Accept action to complete", context) + + assertFalse(myFixture.editor.inlayModel.hasBlockElements()) + assertNull(FixupService.getInstance(project).getActiveSession()) + } + + fun skip_testUndo() { + val undoFuture = subscribeToTopic(CodyInlineEditActionNotifier.TOPIC_PERFORM_UNDO) + executeDocumentCodeAction() + // The Accept/Retry/Undo group is now showing. + triggerAction(FixupSession.ACTION_UNDO) + + val context = undoFuture.get() + assertNotNull("Timed out waiting for Undo action", context) + } + + private fun executeDocumentCodeAction() { + assertFalse(myFixture.editor.inlayModel.hasBlockElements()) + // Execute the action and await the working group lens. + runInEdtAndWait { EditorTestUtil.executeAction(myFixture.editor, "cody.documentCodeAction") } + } + + private fun configureFixture() { + // This is wherever src/integrationTest/resources is on the box running the tests. + val testResourcesDir = File(System.getProperty("test.resources.dir")) + assertTrue(testResourcesDir.exists()) + + // During test runs this is set by IntelliJ to a private temp folder. + // We pass it to the Agent during initialization. + val workspaceRootUri = ConfigUtil.getWorkspaceRootPath(project) + + // We copy the test resources there manually, bypassing Gradle, which is picky. + testDataPath = Paths.get(workspaceRootUri.toString(), "src/").toFile() + testResourcesDir.copyRecursively(testDataPath, overwrite = true) + + // This useful setting lets us tell the fixture to look where we copied them. + myFixture.testDataPath = testDataPath.path + + // The file we pass to configureByFile must be relative to testDataPath. + val projectFile = "testProjects/documentCode/src/main/java/Foo.java" + val sourcePath = Paths.get(testDataPath.path, projectFile).toString() + assertTrue(File(sourcePath).exists()) + myFixture.configureByFile(projectFile) + + initCaretPosition() + } + + // This provides a crude mechanism for specifying the caret position in the test file. + private fun initCaretPosition() { + runInEdtAndWait { + val virtualFile = myFixture.file.virtualFile + val document = FileDocumentManager.getInstance().getDocument(virtualFile)!! + val caretToken = "[[caret]]" + val caretIndex = document.text.indexOf(caretToken) + + if (caretIndex != -1) { // Remove caret token from doc + WriteCommandAction.runWriteCommandAction(project) { + document.deleteString(caretIndex, caretIndex + caretToken.length) + } + // Place the caret at the position where the token was found. + myFixture.editor.caretModel.moveToOffset(caretIndex) + // myFixture.editor.selectionModel.setSelection(caretIndex, caretIndex) + } else { + initSelectionRange() + } + } + } + + // Provides a mechanism to specify the selection range via [[start]] and [[end]]. + // The tokens are removed and the range is selected, notifying the Agent. + private fun initSelectionRange() { + runInEdtAndWait { + val virtualFile = myFixture.file.virtualFile + val document = FileDocumentManager.getInstance().getDocument(virtualFile)!! + val startToken = "[[start]]" + val endToken = "[[end]]" + val start = document.text.indexOf(startToken) + val end = document.text.indexOf(endToken) + // Remove the tokens from the document. + if (start != -1 && end != -1) { + ApplicationManager.getApplication().runWriteAction { + document.deleteString(start, start + startToken.length) + document.deleteString(end, end + endToken.length) + } + myFixture.editor.selectionModel.setSelection(start, end) + } else { + logger.warn("No caret or selection range specified in test file.") + } + } + } + + private fun subscribeToTopic( + topic: Topic, + ): CompletableFuture { + val future = + CompletableFuture() + .completeOnTimeout(null, ASYNC_WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + project.messageBus + .connect() + .subscribe( + topic, + object : CodyInlineEditActionNotifier { + override fun afterAction(context: CodyInlineEditActionNotifier.Context) { + future.complete(context) + } + }) + return future + } + + // Run the IDE action specified by actionId. + private fun triggerAction(actionId: String) { + val action = ActionManager.getInstance().getAction(actionId) + action.actionPerformed( + AnActionEvent( + null, + mock(DataContext::class.java), + "", + action.templatePresentation.clone(), + ActionManager.getInstance(), + 0)) + } + + private fun hasJavadocComment(text: String): Boolean { + // TODO: Check for the exact contents once they are frozen. + val javadocPattern = Pattern.compile("/\\*\\*.*?\\*/", Pattern.DOTALL) + return javadocPattern.matcher(text).find() + } + + companion object { + + // TODO: find the lowest value this can be for production, and use it + // If it's too low the test may be flaky. + // const val ASYNC_WAIT_TIMEOUT_SECONDS = 15000L + + const val ASYNC_WAIT_TIMEOUT_SECONDS = 15L + } +} diff --git a/src/integrationTest/resources/testProjects/documentCode/.gitignore b/src/integrationTest/resources/testProjects/documentCode/.gitignore new file mode 100644 index 0000000000..bff2d76296 --- /dev/null +++ b/src/integrationTest/resources/testProjects/documentCode/.gitignore @@ -0,0 +1 @@ +*.iml diff --git a/src/integrationTest/resources/testProjects/documentCode/src/main/java/Foo.java b/src/integrationTest/resources/testProjects/documentCode/src/main/java/Foo.java new file mode 100644 index 0000000000..71e4baf073 --- /dev/null +++ b/src/integrationTest/resources/testProjects/documentCode/src/main/java/Foo.java @@ -0,0 +1,17 @@ +import java.util.*; + +public class Foo { + + public void foo() { + List mystery = new ArrayList<>(); + mystery.add(0); + mystery.add(1); + [[caret]]for (int i = 2; i < 10; i++) { + mystery.add(mystery.get(i - 1) + mystery.get(i - 2)); + } + + for (int i = 0; i < 10; i++) { + System.out.println(mystery.get(i)); + } + } +} diff --git a/src/main/java/com/sourcegraph/cody/agent/CodyAgentClient.java b/src/main/java/com/sourcegraph/cody/agent/CodyAgentClient.java index 6d6276785c..3b67449ea6 100644 --- a/src/main/java/com/sourcegraph/cody/agent/CodyAgentClient.java +++ b/src/main/java/com/sourcegraph/cody/agent/CodyAgentClient.java @@ -21,20 +21,20 @@ public class CodyAgentClient { private static final Logger logger = Logger.getInstance(CodyAgentClient.class); // Callback that is invoked when the agent sends a "chat/updateMessageInProgress" notification. - @Nullable Consumer onNewMessage; + @Nullable public Consumer onNewMessage; // Callback that is invoked when the agent sends a "setConfigFeatures" message. - @Nullable ConfigFeaturesObserver onSetConfigFeatures; + @Nullable public ConfigFeaturesObserver onSetConfigFeatures; // Callback that is invoked on webview messages which aren't handled by onNewMessage or // onSetConfigFeatures - @Nullable Consumer onReceivedWebviewMessage; + @Nullable public Consumer onReceivedWebviewMessage; // Callback for the "editTask/didUpdate" notification from the agent. - @Nullable Consumer onEditTaskDidUpdate; + @Nullable public Consumer onEditTaskDidUpdate; // Callback for the "editTask/didDelete" notification from the agent. - @Nullable Consumer onEditTaskDidDelete; + @Nullable public Consumer onEditTaskDidDelete; // Callback for the "textDocument/edit" request from the agent. @Nullable Function onTextDocumentEdit; diff --git a/src/main/java/com/sourcegraph/vcs/RepoUtil.java b/src/main/java/com/sourcegraph/vcs/RepoUtil.java index 1757a7486a..b8b150d898 100644 --- a/src/main/java/com/sourcegraph/vcs/RepoUtil.java +++ b/src/main/java/com/sourcegraph/vcs/RepoUtil.java @@ -189,7 +189,8 @@ public static VCSType getVcsType(@NotNull Project project, @NotNull VirtualFile != null) { return VCSType.PERFORCE; } - } catch (ClassNotFoundException e) { + } catch (Exception e) { + // Can throw a ClassNotFoundException or a com.intellij.execution.ExecutionException/others // Perforce plugin is not installed. } diff --git a/src/main/kotlin/com/sourcegraph/cody/agent/CodyAgent.kt b/src/main/kotlin/com/sourcegraph/cody/agent/CodyAgent.kt index e694ab919f..d3ebb3a869 100644 --- a/src/main/kotlin/com/sourcegraph/cody/agent/CodyAgent.kt +++ b/src/main/kotlin/com/sourcegraph/cody/agent/CodyAgent.kt @@ -107,6 +107,7 @@ private constructor( val server = launcher.remoteProxy val listeningToJsonRpc = launcher.startListening() try { + logger.warn("Calling server.initialize") return server .initialize( ClientInfo( @@ -171,6 +172,8 @@ private constructor( processBuilder.environment()["CODY_LOG_EVENT_MODE"] = "connected-instance-only" } + configureIntegrationTesting(processBuilder) + val proxy = HttpConfigurable.getInstance() val proxyUrl = proxy.PROXY_HOST + ":" + proxy.PROXY_PORT if (proxy.USE_HTTP_PROXY) { @@ -211,6 +214,46 @@ private constructor( return AgentConnection.ProcessConnection(process) } + private fun configureIntegrationTesting(processBuilder: ProcessBuilder) { + // N.B. Do not set CODY_TESTING=true -- that is for Agent-side tests. + if (!ConfigUtil.isIntegrationTestModeEnabled()) return + + processBuilder.environment().apply { + this["CODY_RECORDING_NAME"] = "integration-test" + this["CODY_TELEMETRY_EXPORTER"] = "testing" + // N.B. If you set CODY_RECORDING_MODE, you must set CODY_RECORDING_DIRECTORY, + // or the Agent will throw an error and your test will fail. + when (val mode = System.getenv("CODY_RECORDING_MODE")) { + null -> { + logger.warn( + """Polly is not enabled for this test. + Set CODY_RECORDING_MODE and CODY_RECORDING_DIRECTORY + variables to turn on Polly.""" + .trimMargin()) + } + "record", + "replay", + "passthrough" -> { + logger.warn("Cody integration test recording mode: $mode") + this["CODY_RECORDING_MODE"] = mode + this["CODY_RECORD_IF_MISSING"] = "true" + // This flag is for Agent-side integration testing and interferes with ours. + // It seems to be sneaking in somewhere, so we explicitly set it to false. + this["CODY_TESTING"] = "false" + this["CODY_RECORDING_DIRECTORY"] = "recordings" + } + else -> throw CodyAgentException("Unknown CODY_RECORDING_MODE: $mode") + } + } + + val testToken = System.getenv("CODY_INTEGRATION_TEST_TOKEN") + if (testToken is String && testToken.isNotBlank()) { + processBuilder.environment()["CODY_INTEGRATION_TEST_TOKEN"] = testToken + } else { + logger.warn("No access token passed for integration tests") + } + } + @Throws(IOException::class, CodyAgentException::class) private fun startAgentLauncher( process: AgentConnection, diff --git a/src/main/kotlin/com/sourcegraph/cody/agent/CodyAgentService.kt b/src/main/kotlin/com/sourcegraph/cody/agent/CodyAgentService.kt index 285596c1ce..5d816179c0 100644 --- a/src/main/kotlin/com/sourcegraph/cody/agent/CodyAgentService.kt +++ b/src/main/kotlin/com/sourcegraph/cody/agent/CodyAgentService.kt @@ -251,7 +251,8 @@ class CodyAgentService(private val project: Project) : Disposable { restartIfNeeded: Boolean, callback: Consumer, onFailure: Consumer = Consumer {} - ) { + ): CompletableFuture { + val future = CompletableFuture() if (CodyApplicationSettings.instance.isCodyEnabled) { ApplicationManager.getApplication().executeOnPooledThread { runBlocking { @@ -266,27 +267,29 @@ class CodyAgentService(private val project: Project) : Disposable { coWithAgent(project, restartIfNeeded, task) } } + } else { + future.complete(false) // Complete the future with false indicating Cody is disabled. } + return future } @JvmStatic - fun withAgent( - project: Project, - callback: Consumer, - ) = withAgent(project, restartIfNeeded = false, callback = callback) + fun withAgent(project: Project, callback: Consumer): CompletableFuture = + withAgent(project, restartIfNeeded = false, callback = callback) @JvmStatic fun withAgentRestartIfNeeded( project: Project, - callback: Consumer, - ) = withAgent(project, restartIfNeeded = true, callback = callback) + callback: Consumer + ): CompletableFuture = withAgent(project, restartIfNeeded = true, callback = callback) @JvmStatic fun withAgentRestartIfNeeded( project: Project, callback: Consumer, onFailure: Consumer - ) = withAgent(project, restartIfNeeded = true, callback = callback, onFailure = onFailure) + ): CompletableFuture = + withAgent(project, restartIfNeeded = true, callback = callback, onFailure = onFailure) @JvmStatic fun isConnected(project: Project): Boolean { diff --git a/src/main/kotlin/com/sourcegraph/cody/agent/protocol/CodyError.kt b/src/main/kotlin/com/sourcegraph/cody/agent/protocol/CodyError.kt new file mode 100644 index 0000000000..923df29345 --- /dev/null +++ b/src/main/kotlin/com/sourcegraph/cody/agent/protocol/CodyError.kt @@ -0,0 +1,3 @@ +package com.sourcegraph.cody.agent.protocol + +data class CodyError(val message: String, val cause: CodyError? = null, val stack: String? = null) diff --git a/src/main/kotlin/com/sourcegraph/cody/agent/protocol/EditTask.kt b/src/main/kotlin/com/sourcegraph/cody/agent/protocol/EditTask.kt index d2a29c7b06..dc6899c171 100644 --- a/src/main/kotlin/com/sourcegraph/cody/agent/protocol/EditTask.kt +++ b/src/main/kotlin/com/sourcegraph/cody/agent/protocol/EditTask.kt @@ -3,6 +3,7 @@ package com.sourcegraph.cody.agent.protocol data class EditTask( val id: String, val state: CodyTaskState, + val error: CodyError? = null, val selectionRange: Range, val instruction: String? = null ) diff --git a/src/main/kotlin/com/sourcegraph/cody/agent/protocol/ProtocolTextDocument.kt b/src/main/kotlin/com/sourcegraph/cody/agent/protocol/ProtocolTextDocument.kt index 5fdb52d691..cc9a09147a 100644 --- a/src/main/kotlin/com/sourcegraph/cody/agent/protocol/ProtocolTextDocument.kt +++ b/src/main/kotlin/com/sourcegraph/cody/agent/protocol/ProtocolTextDocument.kt @@ -11,8 +11,10 @@ import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.vfs.VirtualFile import com.intellij.util.concurrency.annotations.RequiresEdt import java.awt.Point +import java.net.URI import java.nio.file.FileSystems -import java.util.Locale +import java.util.* +import kotlin.io.path.pathString import kotlin.math.max import kotlin.math.min @@ -190,8 +192,15 @@ private constructor( @JvmStatic fun uriFor(file: VirtualFile): String { - val uri = FileSystems.getDefault().getPath(file.path).toUri().toString() - return uri.replace(Regex("file:///(\\w):/")) { + // Integration test: Convert default filesystem "temp://" scheme to "file://" for Agent. + val initialUri = FileSystems.getDefault().getPath(file.path) + + // Don't let Java's URI library produce syntactically incorrect "file:/src/foo/bar" URIs. + // This construction forces it to have the syntax "file:///src/foo/bar". + val properUri = URI("file", "", initialUri.pathString, null).toString() + + // Canonicalize by lower-casing drive letter, if any. + return properUri.replace(Regex("file:///(\\w):/")) { val driveLetter = it.groups[1]?.value?.lowercase(Locale.getDefault()) ?: return@replace it.value "file:///$driveLetter%3A/" diff --git a/src/main/kotlin/com/sourcegraph/cody/config/ServerAuth.kt b/src/main/kotlin/com/sourcegraph/cody/config/ServerAuth.kt index 231e41d26d..80b3fe14b0 100644 --- a/src/main/kotlin/com/sourcegraph/cody/config/ServerAuth.kt +++ b/src/main/kotlin/com/sourcegraph/cody/config/ServerAuth.kt @@ -1,5 +1,6 @@ package com.sourcegraph.cody.config +import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.Project import com.sourcegraph.config.ConfigUtil @@ -10,6 +11,7 @@ data class ServerAuth( ) object ServerAuthLoader { + val logger = Logger.getInstance(ServerAuth::class.java) @JvmStatic fun loadServerAuth(project: Project): ServerAuth { @@ -20,6 +22,14 @@ object ServerAuthLoader { return ServerAuth( defaultAccount.server.url, accessToken, defaultAccount.server.customRequestHeaders) } + if (ConfigUtil.isIntegrationTestModeEnabled()) { + val token = System.getenv("CODY_INTEGRATION_TEST_TOKEN") + if (token != null) { + return ServerAuth(ConfigUtil.DOTCOM_URL, token, "") + } else { + logger.warn("Integration testing enabled but no CODY_INTEGRATION_TEST_TOKEN passed") + } + } return ServerAuth(ConfigUtil.DOTCOM_URL, "", "") } } diff --git a/src/main/kotlin/com/sourcegraph/cody/edit/CodyInlineEditActionNotifier.kt b/src/main/kotlin/com/sourcegraph/cody/edit/CodyInlineEditActionNotifier.kt new file mode 100644 index 0000000000..bb0016f019 --- /dev/null +++ b/src/main/kotlin/com/sourcegraph/cody/edit/CodyInlineEditActionNotifier.kt @@ -0,0 +1,70 @@ +package com.sourcegraph.cody.edit + +import com.intellij.util.messages.Topic +import com.sourcegraph.cody.agent.protocol.Range +import com.sourcegraph.cody.edit.sessions.FixupSession + +/** Pubsub interface shared by all inline edit notifications that accept a FixupSession. */ +interface CodyInlineEditActionNotifier { + + // Encapsulates the FixupSession and allows adding new fields without breaking subscribers. + data class Context(val session: FixupSession, val selectionRange: Range? = null) + + fun afterAction(context: Context) + + companion object { + @JvmStatic + @Topic.ProjectLevel + val TOPIC_FOLDING_RANGES = + Topic.create( + "Sourcegraph Cody: Received folding ranges", CodyInlineEditActionNotifier::class.java) + + /** Sent when the "Cody is working..." lens is displayed during an inline edit. */ + @JvmStatic + @Topic.ProjectLevel + val TOPIC_DISPLAY_WORKING_GROUP = + Topic.create( + "Sourcegraph Cody: Cody working lens shown", CodyInlineEditActionNotifier::class.java) + + @JvmStatic + @Topic.ProjectLevel + val TOPIC_DISPLAY_ACCEPT_GROUP = + Topic.create( + "Sourcegraph Cody: Accept lens shown", CodyInlineEditActionNotifier::class.java) + + @JvmStatic + @Topic.ProjectLevel + val TOPIC_DISPLAY_ERROR_GROUP = + Topic.create("Sourcegraph Cody: Error lens shown", CodyInlineEditActionNotifier::class.java) + + @JvmStatic + @Topic.ProjectLevel + val TOPIC_PERFORM_UNDO = + Topic.create("Sourcegraph Cody: Undo Inline Edit", CodyInlineEditActionNotifier::class.java) + + @JvmStatic + @Topic.ProjectLevel + val TOPIC_PERFORM_ACCEPT = + Topic.create( + "Sourcegraph Cody: Accept Inline Edit", CodyInlineEditActionNotifier::class.java) + + @JvmStatic + @Topic.ProjectLevel + val TOPIC_PERFORM_CANCEL = + Topic.create( + "Sourcegraph Cody: Cancel Inline Edit", CodyInlineEditActionNotifier::class.java) + + @JvmStatic + @Topic.ProjectLevel + val TOPIC_WORKSPACE_EDIT = + Topic.create( + "Sourcegraph Cody: workspace/edit completed", CodyInlineEditActionNotifier::class.java) + + @JvmStatic + @Topic.ProjectLevel + val TOPIC_TEXT_DOCUMENT_EDIT = + Topic.create( + "Sourcegraph Cody: textDocument/edit completed", + CodyInlineEditActionNotifier::class.java) + } +} diff --git a/src/main/kotlin/com/sourcegraph/cody/edit/EditCommandPrompt.kt b/src/main/kotlin/com/sourcegraph/cody/edit/EditCommandPrompt.kt index 58d453e42c..e9aade73c2 100644 --- a/src/main/kotlin/com/sourcegraph/cody/edit/EditCommandPrompt.kt +++ b/src/main/kotlin/com/sourcegraph/cody/edit/EditCommandPrompt.kt @@ -34,6 +34,7 @@ import com.sourcegraph.cody.edit.EditUtil.namedButton import com.sourcegraph.cody.edit.EditUtil.namedLabel import com.sourcegraph.cody.edit.EditUtil.namedPanel import com.sourcegraph.cody.edit.sessions.EditCodeSession +import com.sourcegraph.cody.edit.sessions.FixupSession import com.sourcegraph.cody.ui.FrameMover import java.awt.BorderLayout import java.awt.Color @@ -85,8 +86,6 @@ class EditCommandPrompt( private val offset = editor.caretModel.primaryCaret.offset - private val escapeKeyStroke = KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0) - private var connection: MessageBusConnection? = null private val isDisposed: AtomicBoolean = AtomicBoolean(false) @@ -103,6 +102,8 @@ class EditCommandPrompt( KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, InputEvent.CTRL_DOWN_MASK) } + private val escapeKeyStroke = KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0) + private val okButton = namedButton("ok-button").apply { text = "Edit Code" @@ -547,28 +548,37 @@ class EditCommandPrompt( @RequiresEdt fun performOKAction() { - val text = instructionsField.text - if (text.isBlank()) { - clearActivePrompt() - return - } - val activeSession = controller.getActiveSession() - promptHistory.add(text) - if (editor.project == null) { - val msg = "Null project for new edit session" - controller.getActiveSession()?.showErrorGroup(msg) - logger.warn(msg) - return - } + try { + val text = instructionsField.text + if (text.isBlank()) return - activeSession?.let { session -> - session.afterSessionFinished { - startEditCodeSession(text, if (session.isInserted) "insert" else "edit") + promptHistory.add(text) + val session = controller.getActiveSession() + validateProject(session) + + val mode = if (session?.isInserted == true) "insert" else "edit" + fun editCode() = runInEdt { + EditCodeSession(controller, editor, text, llmDropdown.item, mode) + } + + if (session != null) { + session.afterSessionFinished { editCode() } + session.undo() + } else { + editCode() } - session.undo() - } ?: run { startEditCodeSession(text) } + } finally { + clearActivePrompt() + } + } - clearActivePrompt() + private fun validateProject(session: FixupSession?): Boolean { + return if (editor.project == null) { + // TODO move these to Cody bundle + session?.displayError("Error initiating Code Edit", "Could not find current Project") + logger.warn("Project was null when trying to add an edit session") + false + } else true } private fun startEditCodeSession(text: String, mode: String = "edit") { diff --git a/src/main/kotlin/com/sourcegraph/cody/edit/FixupService.kt b/src/main/kotlin/com/sourcegraph/cody/edit/FixupService.kt index 68ed678ac7..5e9efa666b 100644 --- a/src/main/kotlin/com/sourcegraph/cody/edit/FixupService.kt +++ b/src/main/kotlin/com/sourcegraph/cody/edit/FixupService.kt @@ -17,6 +17,7 @@ import com.sourcegraph.cody.edit.sessions.TestCodeSession import com.sourcegraph.cody.ignore.ActionInIgnoredFileNotification import com.sourcegraph.cody.ignore.IgnoreOracle import com.sourcegraph.cody.ignore.IgnorePolicy +import com.sourcegraph.config.ConfigUtil import com.sourcegraph.cody.statusbar.CodyStatus import com.sourcegraph.cody.statusbar.CodyStatusService import com.sourcegraph.config.ConfigUtil.isCodyEnabled @@ -81,6 +82,13 @@ class FixupService(val project: Project) : Disposable { return false } val policy = IgnoreOracle.getInstance(project).policyForEditor(editor) + + // TODO: We'll have to figure out a way to integration-test the ignore stuff. + // But for now, the policy comes back null during testing, which would normally + // ignore it. + if (ConfigUtil.isIntegrationTestModeEnabled()) { + return true + } if (policy != IgnorePolicy.USE) { runInEdt { ActionInIgnoredFileNotification().notify(project) } logger.warn("Ignoring file for inline edits: $editor, policy=$policy") diff --git a/src/main/kotlin/com/sourcegraph/cody/edit/sessions/FixupSession.kt b/src/main/kotlin/com/sourcegraph/cody/edit/sessions/FixupSession.kt index 3a7a5ca75d..c6390f8066 100644 --- a/src/main/kotlin/com/sourcegraph/cody/edit/sessions/FixupSession.kt +++ b/src/main/kotlin/com/sourcegraph/cody/edit/sessions/FixupSession.kt @@ -16,6 +16,7 @@ import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.messages.Topic import com.sourcegraph.cody.agent.CodyAgent import com.sourcegraph.cody.agent.CodyAgentCodebase import com.sourcegraph.cody.agent.CodyAgentService @@ -28,6 +29,7 @@ import com.sourcegraph.cody.agent.protocol.Range import com.sourcegraph.cody.agent.protocol.TaskIdParam import com.sourcegraph.cody.agent.protocol.TextEdit import com.sourcegraph.cody.agent.protocol.WorkspaceEditParams +import com.sourcegraph.cody.edit.CodyInlineEditActionNotifier import com.sourcegraph.cody.edit.EditCommandPrompt import com.sourcegraph.cody.edit.FixupService import com.sourcegraph.cody.edit.exception.EditCreationException @@ -43,6 +45,7 @@ import java.util.concurrent.CancellationException import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletionException import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean /** * Common functionality for commands that let the agent edit the code inline, such as adding a doc @@ -64,8 +67,12 @@ abstract class FixupSession( // This is passed back by the Agent when we initiate the editing task. @Volatile var taskId: String? = null - // The current lens group. Changes as the state machine proceeds. - private var lensGroup: LensWidgetGroup? = null + var lensGroup: LensWidgetGroup? = null + private set + + private val showedAcceptLens = AtomicBoolean(false) + private val isInlineEditInProgress = AtomicBoolean(false) + val isDisposed = AtomicBoolean(false) private var selectionRange: RangeMarker? = null @@ -80,6 +87,10 @@ abstract class FixupSession( private val performedActions: MutableList = mutableListOf() + private val defaultErrorText by lazy { + "Cody failed to ${if (this is DocumentCodeSession) "document" else "edit"} this code" + } + private val cancellationToken = CancellationToken() private val completionFuture: CompletableFuture = @@ -99,7 +110,8 @@ abstract class FixupSession( @RequiresEdt private fun triggerFixupAsync() { - // Those lookups require us to be on the EDT. + ApplicationManager.getApplication().assertIsDispatchThread() + val file = FileDocumentManager.getInstance().getFile(document) val textFile = file?.let { ProtocolTextDocument.fromVirtualFile(editor, it) } ?: return @@ -108,30 +120,31 @@ abstract class FixupSession( try { fixupService.startNewSession(this) - // Spend a turn to get folding ranges before showing lenses. ensureSelectionRange(agent, textFile) showWorkingGroup() makeEditingRequest(agent) .handle { result, error -> - if (error != null || result == null) { - showErrorGroup("Error while generating doc string: $error") + if (error != null) { + displayError("Error while generating code", error) + } else if (result == null) { + displayError("Null result generating code for task $taskId") } else { selectionRange = adjustToDocumentRange(result.selectionRange) } null } .exceptionally { error: Throwable? -> - if (!(error is CancellationException || error is CompletionException)) { - showErrorGroup("Error while generating code: ${error?.localizedMessage}") + if ((error is CancellationException || error is CompletionException)) { + cancel() + } else { + displayError(defaultErrorText, error?.localizedMessage) } - cancel() null } .completeOnTimeout(null, 3, TimeUnit.SECONDS) } catch (e: Exception) { - showErrorGroup("Edit failed: ${e.localizedMessage}") - cancel() + displayError(defaultErrorText, e.localizedMessage) } } } @@ -153,11 +166,16 @@ abstract class FixupSession( selectionRange = selection.toRangeMarker(document, true) try { - val result = - agent.server - .getFoldingRanges(GetFoldingRangeParams(uri = textFile.uri, range = selection)) - .get() - selectionRange = result.range.toRangeMarker(document, true) + agent.server + .getFoldingRanges(GetFoldingRangeParams(uri = textFile.uri, range = selection)) + .thenApply { result -> + selectionRange = result.range.toRangeMarker(document, true) + publishProgressOnEdt( + CodyInlineEditActionNotifier.TOPIC_FOLDING_RANGES, + CodyInlineEditActionNotifier.Context(session = this, selectionRange = result.range)) + result + } + .get() } catch (e: Exception) { logger.warn("Error getting folding range", e) } @@ -177,11 +195,14 @@ abstract class FixupSession( CodyTaskState.Formatting -> { taskId = task.id } - // Tasks remain in this state until explicit accept/undo/cancel. + // Tasks remain in this state until explicit or automatic accept/undo/cancel. CodyTaskState.Applied -> showAcceptGroup() - // Then they transition to finished. - CodyTaskState.Finished -> dispose() - CodyTaskState.Error -> dispose() + + // Then they transition to either Finished or Error. + CodyTaskState.Finished -> cancellationToken.dispose() + + // We do not dispose until the error is displayed to the user and closed. + CodyTaskState.Error -> displayError(defaultErrorText, task.error?.message) CodyTaskState.Pending -> {} } } @@ -196,6 +217,7 @@ abstract class FixupSession( } catch (x: Exception) { logger.warn("Error disposing previous lens group", x) } + if (isDisposed.get()) return@runInEdt lensGroup = group var range = @@ -211,14 +233,16 @@ abstract class FixupSession( val position = Position(editor.caretModel.currentCaret.logicalPosition.line, 0) range = Range(start = position, end = position) } - val future = group.show(range) + // Make sure the lens is visible. + val future = group.show(range) ApplicationManager.getApplication().invokeLater { if (!editor.isDisposed) { val logicalPosition = LogicalPosition(range.start.line, range.start.character) editor.scrollingModel.scrollTo(logicalPosition, ScrollType.CENTER) } } + if (!ApplicationManager.getApplication().isDispatchThread) { // integration test future.get() } @@ -235,6 +259,7 @@ abstract class FixupSession( private fun showWorkingGroup() { showLensGroup(LensGroupFactory(this).createTaskWorkingGroup()) documentListener.setAcceptLensGroupShown(false) + publishProgress(CodyInlineEditActionNotifier.TOPIC_DISPLAY_WORKING_GROUP) } private fun showAcceptGroup() { @@ -243,12 +268,28 @@ abstract class FixupSession( // This is the specific moment after which any edits to the document, // including edits generated by the Agent, will result in an auto-accept. documentListener.setAcceptLensGroupShown(true) + + publishProgress(CodyInlineEditActionNotifier.TOPIC_DISPLAY_ACCEPT_GROUP) } - fun showErrorGroup(hoverText: String) { - showLensGroup(LensGroupFactory(this).createErrorGroup(hoverText)) + private fun showErrorGroup(labelText: String, hoverText: String? = null) { + runInEdt { + showLensGroup(LensGroupFactory(this).createErrorGroup(labelText, hoverText)) + publishProgress(CodyInlineEditActionNotifier.TOPIC_DISPLAY_ERROR_GROUP) + } } + /** + * Puts up the error lens group with the specified message and optional hover-text. The message + * should be short, no more than about 60 characters. The hover text can be longer and include + * more diagnostic information. + */ + fun displayError(text: String, hoverText: String? = null) { + showErrorGroup(text, hoverText ?: "No additional info from Agent") + } + + fun displayError(text: String, error: Throwable) {} + /** Subclass sends a fixup command to the agent, and returns the initial task. */ abstract fun makeEditingRequest(agent: CodyAgent): CompletableFuture @@ -262,6 +303,7 @@ abstract class FixupSession( logger.warn("Error sending editTask/accept for taskId: ${x.localizedMessage}") dispose() } + publishProgress(CodyInlineEditActionNotifier.TOPIC_PERFORM_ACCEPT) } fun cancel() { @@ -289,6 +331,7 @@ abstract class FixupSession( } catch (x: Exception) { showErrorGroup("Error sending editTask/undo for taskId: ${x.localizedMessage}") } + publishProgress(CodyInlineEditActionNotifier.TOPIC_PERFORM_UNDO) } fun showRetryPrompt() { @@ -310,7 +353,6 @@ abstract class FixupSession( op.uri?.let { updateEditorIfNeeded(it) } - // TODO: We need to support the file-level operations. when (op.type) { "create-file" -> { logger.warn("Workspace edit operation created a file: ${op.uri}") @@ -334,6 +376,7 @@ abstract class FixupSession( "DocumentCommand session received unknown workspace edit operation: ${op.type}") } } + publishProgress(CodyInlineEditActionNotifier.TOPIC_WORKSPACE_EDIT) } private fun updateEditorIfNeeded(path: String) { @@ -356,42 +399,51 @@ abstract class FixupSession( } fun performInlineEdits(edits: List) { - // TODO: This is an artifact of the update to concurrent editing tasks. - // We do need to mute any LensGroup listeners, but this is an ugly way to do it. - // There are multiple Lens groups; we need a Document-level listener list. - lensGroup?.withListenersMuted { - if (!controller.isEligibleForInlineEdit(editor)) { - return@withListenersMuted logger.warn("Inline edit not eligible") + isInlineEditInProgress.set(true) + try { + // TODO: This is ugly; find a better way to mute the listeners. + lensGroup?.withListenersMuted { + if (!controller.isEligibleForInlineEdit(editor)) { + logger.warn("Inline edit not eligible") + return@withListenersMuted + } + + performInlineEditActions(edits) } + } finally { + isInlineEditInProgress.set(false) + publishProgress(CodyInlineEditActionNotifier.TOPIC_TEXT_DOCUMENT_EDIT) + } + } - WriteCommandAction.runWriteCommandAction(project) { - val currentActions = - edits.mapNotNull { edit -> - try { - when (edit.type) { - "replace", - "delete" -> ReplaceUndoableAction(project, edit, document) - "insert" -> InsertUndoableAction(project, edit, document) - else -> { - logger.warn("Unknown edit type: ${edit.type}") - null - } + private fun performInlineEditActions(edits: List) { + WriteCommandAction.runWriteCommandAction(project) { + val currentActions = + edits.mapNotNull { edit -> + try { + when (edit.type) { + "replace", + "delete" -> ReplaceUndoableAction(project, edit, document) + "insert" -> InsertUndoableAction(project, edit, document) + else -> { + logger.warn("Unknown edit type: ${edit.type}") + null } - } catch (e: RuntimeException) { - throw EditCreationException(edit, e) } + } catch (e: RuntimeException) { + throw EditCreationException(edit, e) } - - currentActions.forEach { action -> - try { - action.apply() - } catch (e: RuntimeException) { - throw EditExecutionException(action, e) } - } - performedActions += currentActions + currentActions.forEach { action -> + try { + action.apply() + } catch (e: RuntimeException) { + throw EditExecutionException(action, e) + } } + + performedActions += currentActions } } @@ -414,6 +466,7 @@ abstract class FixupSession( } override fun dispose() { + isDisposed.set(true) if (project.isDisposed) return performedActions.forEach { it.dispose() } lensGroup?.dispose() @@ -433,6 +486,29 @@ abstract class FixupSession( return lensGroup?.isErrorGroup == true } + private fun publishProgress(topic: Topic) { + ApplicationManager.getApplication().executeOnPooledThread { + project.messageBus + .syncPublisher(topic) + .afterAction(CodyInlineEditActionNotifier.Context(session = this)) + } + } + + private fun publishProgressOnEdt( + topic: Topic, + context: CodyInlineEditActionNotifier.Context? = null + ) { + ApplicationManager.getApplication().invokeLater { + val progressContext = context ?: CodyInlineEditActionNotifier.Context(session = this) + project.messageBus.syncPublisher(topic).afterAction(progressContext) + } + } + + override fun toString(): String { + val file = FileDocumentManager.getInstance().getFile(editor.document) + return "${this::javaClass.name} for ${file?.path ?: "unknown file"}" + } + companion object { // JetBrains Actions that we fire when the lenses are clicked. const val ACTION_ACCEPT = "cody.inlineEditAcceptAction" diff --git a/src/main/kotlin/com/sourcegraph/cody/edit/widget/LensAction.kt b/src/main/kotlin/com/sourcegraph/cody/edit/widget/LensAction.kt index 7de160eb12..4fb235e16d 100644 --- a/src/main/kotlin/com/sourcegraph/cody/edit/widget/LensAction.kt +++ b/src/main/kotlin/com/sourcegraph/cody/edit/widget/LensAction.kt @@ -19,7 +19,7 @@ import java.awt.geom.Rectangle2D class LensAction( val group: LensWidgetGroup, private val text: String, - private val actionId: String + val actionId: String ) : LensWidget(group) { private val highlight = diff --git a/src/main/kotlin/com/sourcegraph/cody/edit/widget/LensGroupFactory.kt b/src/main/kotlin/com/sourcegraph/cody/edit/widget/LensGroupFactory.kt index 18ff6aeab6..18bfd01b00 100644 --- a/src/main/kotlin/com/sourcegraph/cody/edit/widget/LensGroupFactory.kt +++ b/src/main/kotlin/com/sourcegraph/cody/edit/widget/LensGroupFactory.kt @@ -35,12 +35,16 @@ class LensGroupFactory(val session: FixupSession) { } } - fun createErrorGroup(tooltip: String, isDocumentCode: Boolean = false): LensWidgetGroup { + fun createErrorGroup( + message: String, // The message to show in the error lens; should be short. + tooltip: String? = null // Can show more detail here. + ): LensWidgetGroup { return LensWidgetGroup(session, session.editor).apply { addLogo(this) addErrorIcon(this) - val verb = if (isDocumentCode) "document" else "edit" - addLabel(this, "Cody failed to $verb this code").apply { hoverText = tooltip } + if (tooltip != null) { + addLabel(this, message).apply { hoverText = tooltip } + } addSeparator(this) addAction(this, "Dismiss", FixupSession.ACTION_DISMISS) addSeparator(this) diff --git a/src/main/kotlin/com/sourcegraph/cody/edit/widget/LensHotkey.kt b/src/main/kotlin/com/sourcegraph/cody/edit/widget/LensHotkey.kt index 8fb446cbca..cc69e37494 100644 --- a/src/main/kotlin/com/sourcegraph/cody/edit/widget/LensHotkey.kt +++ b/src/main/kotlin/com/sourcegraph/cody/edit/widget/LensHotkey.kt @@ -5,7 +5,7 @@ import java.awt.Font import java.awt.FontMetrics import java.awt.Graphics2D -class LensHotkey(group: LensWidgetGroup, private val text: String) : LensLabel(group, text) { +class LensHotkey(group: LensWidgetGroup, text: String) : LensLabel(group, text) { private val hotkeyHighlightColor = JBColor(0xDDDDDD, 0x252629) @@ -15,7 +15,6 @@ class LensHotkey(group: LensWidgetGroup, private val text: String) : LensLabel(g return fontMetrics.stringWidth(text) + 8 } - @Suppress("UseJBColor") override fun paint(g: Graphics2D, x: Float, y: Float) { // Resize font and get new metrics val originalFont = g.font diff --git a/src/main/kotlin/com/sourcegraph/cody/edit/widget/LensLabel.kt b/src/main/kotlin/com/sourcegraph/cody/edit/widget/LensLabel.kt index d273424145..a2ad27b38a 100644 --- a/src/main/kotlin/com/sourcegraph/cody/edit/widget/LensLabel.kt +++ b/src/main/kotlin/com/sourcegraph/cody/edit/widget/LensLabel.kt @@ -6,8 +6,7 @@ import com.intellij.util.ui.UIUtil import java.awt.FontMetrics import java.awt.Graphics2D -@Suppress("UseJBColor") -open class LensLabel(group: LensWidgetGroup, private val text: String) : LensWidget(group) { +open class LensLabel(group: LensWidgetGroup, val text: String) : LensWidget(group) { override fun calcWidthInPixels(fontMetrics: FontMetrics): Int = fontMetrics.stringWidth(text) diff --git a/src/main/kotlin/com/sourcegraph/cody/edit/widget/LensSpinner.kt b/src/main/kotlin/com/sourcegraph/cody/edit/widget/LensSpinner.kt index 7e9e1640ca..43523b9aa7 100644 --- a/src/main/kotlin/com/sourcegraph/cody/edit/widget/LensSpinner.kt +++ b/src/main/kotlin/com/sourcegraph/cody/edit/widget/LensSpinner.kt @@ -52,7 +52,11 @@ class LensSpinner(group: LensWidgetGroup, private val icon: Icon) : LensWidget(g } override fun dispose() { - stop() + try { + stop() + } catch (x: Exception) { + logger.error(x) + } } override fun toString(): String { diff --git a/src/main/kotlin/com/sourcegraph/cody/edit/widget/LensWidgetGroup.kt b/src/main/kotlin/com/sourcegraph/cody/edit/widget/LensWidgetGroup.kt index 8add71d88c..40152c1754 100644 --- a/src/main/kotlin/com/sourcegraph/cody/edit/widget/LensWidgetGroup.kt +++ b/src/main/kotlin/com/sourcegraph/cody/edit/widget/LensWidgetGroup.kt @@ -95,10 +95,12 @@ class LensWidgetGroup(val session: FixupSession, parentComponent: Editor) : var isErrorGroup = false init { - Disposer.register(session, this) - editor.addEditorMouseListener(mouseClickListener) - editor.addEditorMouseMotionListener(mouseMotionListener) - addedListeners.set(true) + if (!session.isDisposed.get()) { + Disposer.register(session, this) + editor.addEditorMouseListener(mouseClickListener) + editor.addEditorMouseMotionListener(mouseMotionListener) + addedListeners.set(true) + } // Listen for color theme changes. ApplicationManager.getApplication() @@ -326,7 +328,7 @@ class LensWidgetGroup(val session: FixupSession, parentComponent: Editor) : } } - /** Immediately hides and discards this inlay and widget group. */ + /** Hides and discards this inlay and widget group. */ override fun dispose() { // We work extra hard to ensure this method is idempotent and robust, // because IntelliJ (annoyingly) logs an assertion if you try to remove diff --git a/src/main/kotlin/com/sourcegraph/cody/test/NonEdtIdeaTestExecutionPolicy.kt b/src/main/kotlin/com/sourcegraph/cody/test/NonEdtIdeaTestExecutionPolicy.kt new file mode 100644 index 0000000000..639efdbe8b --- /dev/null +++ b/src/main/kotlin/com/sourcegraph/cody/test/NonEdtIdeaTestExecutionPolicy.kt @@ -0,0 +1,21 @@ +package com.sourcegraph.cody.test + +import com.intellij.testFramework.fixtures.IdeaTestExecutionPolicy + +/** + * Used for all Cody JetBrains integration tests. You have to specify it via the System property + * `idea.test.execution.policy` in order to run the tests. + */ +@Suppress("unused") +class NonEdtIdeaTestExecutionPolicy : IdeaTestExecutionPolicy() { + + override fun getName(): String = javaClass.name + + /** + * This setting enables our integration tests. If they use the default policy and run on the EDT, + * then they either deadlock or finish prematurely, because they cannot block on our long-running + * multithreaded async backend operations. With this set to false, we run on the JUnit runner + * thread, which can block. + */ + override fun runInDispatchThread() = false +} diff --git a/src/main/kotlin/com/sourcegraph/config/ConfigUtil.kt b/src/main/kotlin/com/sourcegraph/config/ConfigUtil.kt index 7363015c21..b997d58ade 100644 --- a/src/main/kotlin/com/sourcegraph/config/ConfigUtil.kt +++ b/src/main/kotlin/com/sourcegraph/config/ConfigUtil.kt @@ -44,10 +44,14 @@ object ConfigUtil { /** * Returns true if the specified feature flag is enabled. Feature flags are currently set in the * environment variable CODY_JETBRAINS_FEATURES. The format is - * CODY_JETBRAINS_FEATURES=cody.feature.1=true,cody.feature.2=false. The value should be unquoted - * in your run configuration, but quoted in the env var; e.g., + * + * ``` + * CODY_JETBRAINS_FEATURES=cody.feature.1=true,cody.feature.2=false + * ``` + * + * For instance: * ``` - * export CODY_JETBRAINS_FEATURES="cody.feature.1=true,cody.feature.2=false" + * export CODY_JETBRAINS_FEATURES=cody.feature.inline-edits=true * ``` * * @param flagName The name of the feature flag @@ -175,4 +179,7 @@ object ConfigUtil { fun getShouldAcceptNonTrustedCertificatesAutomatically(): Boolean { return CodyApplicationSettings.instance.shouldAcceptNonTrustedCertificatesAutomatically } + + @JvmStatic + fun isIntegrationTestModeEnabled() = java.lang.Boolean.getBoolean("cody.integration.testing") } diff --git a/src/test/kotlin/com/sourcegraph/cody/agent/CurrentConfigFeaturesTest.kt b/src/test/kotlin/com/sourcegraph/cody/agent/CurrentConfigFeaturesTest.kt index 97e623fe22..9fe0243bd4 100644 --- a/src/test/kotlin/com/sourcegraph/cody/agent/CurrentConfigFeaturesTest.kt +++ b/src/test/kotlin/com/sourcegraph/cody/agent/CurrentConfigFeaturesTest.kt @@ -3,7 +3,7 @@ package com.sourcegraph.cody.agent import java.util.concurrent.CopyOnWriteArrayList import org.awaitility.kotlin.await import org.awaitility.kotlin.until -import org.hamcrest.CoreMatchers.hasItems +import org.hamcrest.Matchers.hasItems import org.junit.Assert.assertEquals import org.junit.Assert.assertThat import org.junit.Test