feat: add conversations tags support

This commit is contained in:
Carl-Robert Linnupuu 2025-06-21 01:33:35 +01:00
parent 465feafc9f
commit 901ce5a01e
25 changed files with 921 additions and 33 deletions

View file

@ -12,7 +12,7 @@ jsoup = "1.19.1"
jtokkit = "1.1.0"
junit = "5.12.1"
kotlin = "2.1.20"
llm-client = "0.8.43"
llm-client = "0.8.44"
okio = "3.10.2"
tree-sitter = "0.24.5"
grpc = "1.71.0"

View file

@ -16,11 +16,16 @@ public class Message {
private String prompt;
private String response;
private List<String> referencedFilePaths;
private List<UUID> conversationsHistoryIds;
private String imageFilePath;
private boolean webSearchIncluded;
private DocumentationDetails documentationDetails;
private String personaName;
public Message() {
this.id = UUID.randomUUID();
}
public Message(String prompt, String response) {
this(prompt);
this.response = response;
@ -60,6 +65,14 @@ public class Message {
this.referencedFilePaths = referencedFilePaths;
}
public List<UUID> getConversationsHistoryIds() {
return conversationsHistoryIds;
}
public void setConversationsHistoryIds(List<UUID> conversationsHistoryIds) {
this.conversationsHistoryIds = conversationsHistoryIds;
}
public @Nullable String getImageFilePath() {
return imageFilePath;
}

View file

@ -37,11 +37,13 @@ import ee.carlrobert.codegpt.toolwindow.ui.ChatToolWindowLandingPanel;
import ee.carlrobert.codegpt.toolwindow.ui.ResponseMessagePanel;
import ee.carlrobert.codegpt.toolwindow.ui.UserMessagePanel;
import ee.carlrobert.codegpt.ui.OverlayUtil;
import ee.carlrobert.codegpt.ui.textarea.ConversationTagProcessor;
import ee.carlrobert.codegpt.ui.textarea.UserInputPanel;
import ee.carlrobert.codegpt.ui.textarea.header.tag.EditorTagDetails;
import ee.carlrobert.codegpt.ui.textarea.header.tag.FileTagDetails;
import ee.carlrobert.codegpt.ui.textarea.header.tag.FolderTagDetails;
import ee.carlrobert.codegpt.ui.textarea.header.tag.GitCommitTagDetails;
import ee.carlrobert.codegpt.ui.textarea.header.tag.HistoryTagDetails;
import ee.carlrobert.codegpt.ui.textarea.header.tag.PersonaTagDetails;
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagDetails;
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager;
@ -169,6 +171,7 @@ public class ChatToolWindowTabPanel implements Disposable {
.conversationType(conversationType)
.imageDetailsFromPath(CodeGPTKeys.IMAGE_ATTACHMENT_FILE_PATH.get(project))
.referencedFiles(getReferencedFiles(selectedTags))
.history(getHistory(getSelectedTags()))
.psiStructure(psiStructure);
findTagOfType(selectedTags, PersonaTagDetails.class)
@ -189,6 +192,32 @@ public class ChatToolWindowTabPanel implements Disposable {
.toList();
}
private List<UUID> getConversationHistoryIds(List<? extends TagDetails> tags) {
return tags.stream()
.map(it -> {
if (it instanceof HistoryTagDetails tagDetails) {
return tagDetails.getConversationId();
}
return null;
})
.filter(Objects::nonNull)
.toList();
}
private List<Conversation> getHistory(List<? extends TagDetails> tags) {
return tags.stream()
.map(it -> {
if (it instanceof HistoryTagDetails tagDetails) {
return ConversationTagProcessor.Companion.getConversation(
tagDetails.getConversationId());
}
return null;
})
.filter(Objects::nonNull)
.distinct()
.toList();
}
private VirtualFile getVirtualFile(TagDetails tag) {
VirtualFile virtualFile = null;
if (tag.getSelected()) {
@ -392,6 +421,11 @@ public class ChatToolWindowTabPanel implements Disposable {
messageBuilder.withReferencedFiles(referencedFiles);
}
List<UUID> conversationHistoryIds = getConversationHistoryIds(appliedTags);
if (!conversationHistoryIds.isEmpty()) {
messageBuilder.withConversationHistoryIds(conversationHistoryIds);
}
String attachedImagePath = CodeGPTKeys.IMAGE_ATTACHMENT_FILE_PATH.get(project);
if (attachedImagePath != null) {
messageBuilder.withImage(attachedImagePath);

View file

@ -188,6 +188,7 @@ class PsiStructureRepository(
// Maybe need recursive find all files
is FolderTagDetails -> null
is HistoryTagDetails -> null
is EditorSelectionTagDetails -> null
is DocumentationTagDetails -> null
is CurrentGitChangesTagDetails -> null
@ -211,6 +212,7 @@ class PsiStructureRepository(
// Maybe need recursive find all files
is FolderTagDetails -> false
is HistoryTagDetails -> false
is DocumentationTagDetails -> false
is CurrentGitChangesTagDetails -> false
is GitCommitTagDetails -> false
@ -235,6 +237,7 @@ class PsiStructureRepository(
// Maybe need recursive find all files
is FolderTagDetails -> null
is HistoryTagDetails -> null
is DocumentationTagDetails -> null
is CurrentGitChangesTagDetails -> null
is GitCommitTagDetails -> null

View file

@ -20,6 +20,7 @@ class ChatCompletionParameters private constructor(
var sessionId: UUID?,
var retry: Boolean,
var imageDetails: ImageDetails?,
var history: List<Conversation>?,
var referencedFiles: List<ReferencedFile>?,
var personaDetails: PersonaDetails?,
var psiStructure: Set<ClassStructure>?,
@ -42,6 +43,7 @@ class ChatCompletionParameters private constructor(
private var conversationType: ConversationType = ConversationType.DEFAULT
private var retry: Boolean = false
private var imageDetails: ImageDetails? = null
private var history: List<Conversation>? = null
private var referencedFiles: List<ReferencedFile>? = null
private var personaDetails: PersonaDetails? = null
private var psiStructure: Set<ClassStructure>? = null
@ -64,6 +66,8 @@ class ChatCompletionParameters private constructor(
fun gitDiff(gitDiff: String) = apply { this.gitDiff = gitDiff }
fun history(history: List<Conversation>?) = apply { this.history = history }
fun referencedFiles(referencedFiles: List<ReferencedFile>?) =
apply { this.referencedFiles = referencedFiles }
@ -79,6 +83,7 @@ class ChatCompletionParameters private constructor(
sessionId,
retry,
imageDetails,
history,
referencedFiles,
personaDetails,
psiStructure,

View file

@ -11,6 +11,7 @@ import ee.carlrobert.codegpt.completions.factory.OpenAIRequestFactory.Companion.
import ee.carlrobert.codegpt.psistructure.ClassStructureSerializer
import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings
import ee.carlrobert.codegpt.settings.service.codegpt.CodeGPTServiceSettings
import ee.carlrobert.codegpt.ui.textarea.ConversationTagProcessor
import ee.carlrobert.codegpt.util.file.FileUtil
import ee.carlrobert.llm.client.codegpt.request.chat.*
import ee.carlrobert.llm.client.openai.completion.request.OpenAIChatCompletionStandardMessage
@ -22,7 +23,9 @@ class CodeGPTRequestFactory(private val classStructureSerializer: ClassStructure
val model = service<CodeGPTServiceSettings>().state.chatCompletionSettings.model
val configuration = service<ConfigurationSettings>().state
val requestBuilder: ChatCompletionRequest.Builder =
ChatCompletionRequest.Builder(buildOpenAIMessages(model, params, emptyList()))
ChatCompletionRequest.Builder(
buildOpenAIMessages(model, params, emptyList(), emptyList())
)
.setModel(model)
.setSessionId(params.sessionId)
.setStream(true)
@ -74,11 +77,15 @@ class CodeGPTRequestFactory(private val classStructureSerializer: ClassStructure
)
}.orEmpty()
val contextFilesWithPsi = contextFiles + psiContext
if (contextFilesWithPsi.isNotEmpty()) {
requestBuilder.setContext(AdditionalRequestContext(contextFilesWithPsi))
val conversationsHistory = params.history?.joinToString("\n\n") {
ConversationTagProcessor.formatConversation(it)
}
requestBuilder.setContext(
AdditionalRequestContext(
contextFiles + psiContext,
conversationsHistory
)
)
return requestBuilder.build()
}

View file

@ -25,7 +25,7 @@ class CustomOpenAIRequestFactory : BaseRequestFactory() {
.active
val request = buildCustomOpenAIChatCompletionRequest(
activeService.chatCompletionSettings,
OpenAIRequestFactory.buildOpenAIMessages(null, params, params.referencedFiles, params.psiStructure),
OpenAIRequestFactory.buildOpenAIMessages(null, params, params.referencedFiles, params.history, params.psiStructure),
true,
getCredential(CredentialKey.CustomServiceApiKey(activeService.name.orEmpty()))
)

View file

@ -20,6 +20,7 @@ class OllamaRequestFactory : BaseRequestFactory() {
model = model,
callParameters = params,
referencedFiles = params.referencedFiles,
conversationsHistory = params.history,
psiStructure = params.psiStructure,
)
)

View file

@ -5,6 +5,7 @@ import com.intellij.openapi.vfs.readText
import ee.carlrobert.codegpt.EncodingManager
import ee.carlrobert.codegpt.ReferencedFile
import ee.carlrobert.codegpt.completions.*
import ee.carlrobert.codegpt.conversations.Conversation
import ee.carlrobert.codegpt.conversations.ConversationsState
import ee.carlrobert.codegpt.psistructure.models.ClassStructure
import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings
@ -13,6 +14,7 @@ import ee.carlrobert.codegpt.settings.prompts.CoreActionsState
import ee.carlrobert.codegpt.settings.prompts.PromptsSettings
import ee.carlrobert.codegpt.settings.prompts.addProjectPath
import ee.carlrobert.codegpt.settings.service.openai.OpenAISettings
import ee.carlrobert.codegpt.ui.textarea.ConversationTagProcessor
import ee.carlrobert.codegpt.util.file.FileUtil.getImageMediaType
import ee.carlrobert.llm.client.openai.completion.OpenAIChatCompletionModel.*
import ee.carlrobert.llm.client.openai.completion.request.*
@ -135,12 +137,14 @@ class OpenAIRequestFactory : CompletionRequestFactory {
model: String?,
callParameters: ChatCompletionParameters,
referencedFiles: List<ReferencedFile>? = null,
conversationsHistory: List<Conversation>? = null,
psiStructure: Set<ClassStructure>? = null
): List<OpenAIChatCompletionMessage> {
val messages = buildOpenAIChatMessages(
model = model,
callParameters = callParameters,
referencedFiles = referencedFiles ?: callParameters.referencedFiles,
conversationsHistory = conversationsHistory ?: callParameters.history,
psiStructure = psiStructure,
)
@ -178,6 +182,7 @@ class OpenAIRequestFactory : CompletionRequestFactory {
model: String?,
callParameters: ChatCompletionParameters,
referencedFiles: List<ReferencedFile>? = null,
conversationsHistory: List<Conversation>? = null,
psiStructure: Set<ClassStructure>? = null
): MutableList<OpenAIChatCompletionMessage> {
val message = callParameters.message
@ -187,20 +192,18 @@ class OpenAIRequestFactory : CompletionRequestFactory {
val selectedPersona = service<PromptsSettings>().state.personas.selectedPersona
if (callParameters.conversationType == ConversationType.DEFAULT && !selectedPersona.disabled) {
val sessionPersonaDetails = callParameters.personaDetails
if (sessionPersonaDetails == null) {
messages.add(
OpenAIChatCompletionStandardMessage(
role,
selectedPersona.instructions?.addProjectPath()
)
)
val instructions = sessionPersonaDetails?.instructions?.addProjectPath()
?: selectedPersona.instructions?.addProjectPath()
val history = if (conversationsHistory.isNullOrEmpty()) {
""
} else {
messages.add(
OpenAIChatCompletionStandardMessage(
role,
sessionPersonaDetails.instructions.addProjectPath()
)
)
conversationsHistory.joinToString("\n\n") {
ConversationTagProcessor.formatConversation(it)
}
}
if (instructions != null) {
messages.add(OpenAIChatCompletionStandardMessage(role, instructions + "\n" + history))
}
}
if (callParameters.conversationType == ConversationType.REVIEW_CHANGES) {

View file

@ -5,6 +5,7 @@ import ee.carlrobert.codegpt.ReferencedFile
import ee.carlrobert.codegpt.conversations.message.Message
import ee.carlrobert.codegpt.ui.textarea.TagProcessorFactory
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagDetails
import java.util.*
class MessageBuilder(private val project: Project, private val text: String) {
private val message = Message("")
@ -24,6 +25,13 @@ class MessageBuilder(private val project: Project, private val text: String) {
return this
}
fun withConversationHistoryIds(conversationHistoryIds: List<UUID>): MessageBuilder {
if (conversationHistoryIds.isNotEmpty()) {
message.conversationsHistoryIds = conversationHistoryIds
}
return this
}
fun withImage(attachedImagePath: String): MessageBuilder {
message.imageFilePath = attachedImagePath
return this

View file

@ -6,6 +6,7 @@ import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.application.runInEdt
import com.intellij.openapi.components.service
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.LocalFileSystem
@ -23,10 +24,15 @@ import ee.carlrobert.codegpt.Icons
import ee.carlrobert.codegpt.conversations.message.Message
import ee.carlrobert.codegpt.events.WebSearchEventDetails
import ee.carlrobert.codegpt.settings.GeneralSettings
import ee.carlrobert.codegpt.toolwindow.chat.ChatToolWindowContentManager
import ee.carlrobert.codegpt.toolwindow.chat.ui.ChatMessageResponseBody
import ee.carlrobert.codegpt.toolwindow.chat.ui.ImageAccordion
import ee.carlrobert.codegpt.toolwindow.chat.ui.SelectedFilesAccordion
import ee.carlrobert.codegpt.ui.IconActionButton
import ee.carlrobert.codegpt.ui.textarea.ConversationTagProcessor
import ee.carlrobert.codegpt.ui.textarea.ConversationTagProcessor.Companion.formatConversation
import ee.carlrobert.codegpt.ui.textarea.lookup.action.HistoryActionItem
import ee.carlrobert.codegpt.util.MarkdownUtil
import java.awt.Image
import java.awt.event.ActionEvent
import java.awt.event.ActionListener
@ -170,6 +176,29 @@ class UserMessagePanel(
)
}
message.conversationsHistoryIds?.let { ids ->
additionalContextPanel.add(JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
isOpaque = false
ids.forEach {
ConversationTagProcessor.getConversation(it)?.let { conversation ->
val title = HistoryActionItem.getConversationTitle(conversation)
val titleLink = ActionLink(title) {
project.service<ChatToolWindowContentManager>()
.displayConversation(conversation)
}.apply {
icon = AllIcons.General.Balloon
toolTipText =
MarkdownUtil.convertMdToHtml(
formatConversation(conversation)
)
}
add(BorderLayoutPanel().addToLeft(titleLink).andTransparent())
}
}
})
}
if (referencedFilePaths.isNotEmpty()) {
application.executeOnPooledThread {
val links = referencedFilePaths

View file

@ -13,6 +13,8 @@ object PromptTextFieldConstants {
"files", "file", "f",
"folders", "folder", "fold",
"git", "g",
"conversations", "conversation", "conv", "c",
"history", "hist", "h",
"personas", "persona", "p",
"docs", "doc", "d",
"mcp", "m",

View file

@ -29,6 +29,7 @@ class SearchManager(
FilesGroupItem(project, tagManager),
FoldersGroupItem(project, tagManager),
GitGroupItem(project),
HistoryGroupItem(),
PersonasGroupItem(tagManager),
DocsGroupItem(tagManager),
MCPGroupItem(),

View file

@ -6,10 +6,14 @@ import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFile
import ee.carlrobert.codegpt.EncodingManager
import ee.carlrobert.codegpt.completions.CompletionRequestUtil
import ee.carlrobert.codegpt.conversations.Conversation
import ee.carlrobert.codegpt.conversations.ConversationsState
import ee.carlrobert.codegpt.conversations.message.Message
import ee.carlrobert.codegpt.ui.textarea.header.tag.*
import ee.carlrobert.codegpt.ui.textarea.lookup.action.HistoryActionItem
import ee.carlrobert.codegpt.util.GitUtil
import git4idea.GitCommit
import java.util.*
object TagProcessorFactory {
@ -17,6 +21,7 @@ object TagProcessorFactory {
return when (tagDetails) {
is FileTagDetails -> FileTagProcessor(tagDetails)
is SelectionTagDetails -> SelectionTagProcessor(tagDetails)
is HistoryTagDetails -> ConversationTagProcessor(tagDetails)
is DocumentationTagDetails -> DocumentationTagProcessor(tagDetails)
is PersonaTagDetails -> PersonaTagProcessor(tagDetails)
is FolderTagDetails -> FolderTagProcessor(tagDetails)
@ -195,4 +200,42 @@ class CurrentGitChangesTagProcessor(
project
)
}
}
class ConversationTagProcessor(
private val tagDetails: HistoryTagDetails
) : TagProcessor {
companion object {
fun getConversation(conversationId: UUID) =
ConversationsState.getCurrentConversation()?.takeIf {
it.id.equals(conversationId)
} ?: ConversationsState.getInstance().conversations.find {
it.id.equals(conversationId)
}
fun formatConversation(conversation: Conversation): String {
val stringBuilder = StringBuilder()
stringBuilder.append(
"# History\n\n"
)
stringBuilder.append(
"## Conversation: ${HistoryActionItem.getConversationTitle(conversation)}\n\n"
)
conversation.messages.forEachIndexed { index, msg ->
stringBuilder.append("**User**: ${msg.prompt}\n\n")
stringBuilder.append("**Assistant**: ${msg.response}\n\n")
stringBuilder.append("\n")
}
return stringBuilder.toString()
}
}
override fun process(message: Message, stringBuilder: StringBuilder) {
if (message.conversationsHistoryIds == null) {
message.conversationsHistoryIds = mutableListOf()
}
message.conversationsHistoryIds?.add(tagDetails.conversationId)
}
}

View file

@ -30,7 +30,8 @@ sealed class TagDetails(
}
}
class EditorTagDetails(val virtualFile: VirtualFile) : TagDetails(virtualFile.name, virtualFile.fileType.icon) {
class EditorTagDetails(val virtualFile: VirtualFile) :
TagDetails(virtualFile.name, virtualFile.fileType.icon) {
private val type: String = "EditorTagDetails"
@ -51,7 +52,8 @@ class EditorTagDetails(val virtualFile: VirtualFile) : TagDetails(virtualFile.na
}
class FileTagDetails(val virtualFile: VirtualFile) : TagDetails(virtualFile.name, virtualFile.fileType.icon) {
class FileTagDetails(val virtualFile: VirtualFile) :
TagDetails(virtualFile.name, virtualFile.fileType.icon) {
private val type: String = "FileTagDetails"
@ -119,4 +121,9 @@ data class FolderTagDetails(var folder: VirtualFile) :
class WebTagDetails : TagDetails("Web", AllIcons.General.Web)
data class HistoryTagDetails(
val conversationId: UUID,
val title: String,
) : TagDetails(title, AllIcons.General.Balloon)
class EmptyTagDetails : TagDetails("")

View file

@ -4,9 +4,12 @@ import com.intellij.codeInsight.lookup.LookupElement
import com.intellij.codeInsight.lookup.LookupElementPresentation
import ee.carlrobert.codegpt.ui.textarea.lookup.AbstractLookupItem
import ee.carlrobert.codegpt.ui.textarea.lookup.LookupActionItem
import java.util.UUID
abstract class AbstractLookupActionItem : AbstractLookupItem(), LookupActionItem {
private val id: UUID = UUID.randomUUID()
override fun setPresentation(element: LookupElement, presentation: LookupElementPresentation) {
presentation.icon = icon
presentation.itemText = displayName
@ -14,6 +17,6 @@ abstract class AbstractLookupActionItem : AbstractLookupItem(), LookupActionItem
}
override fun getLookupString(): String {
return "action_${displayName.replace(" ", "_").lowercase()}"
return "action_${id}"
}
}

View file

@ -0,0 +1,36 @@
package ee.carlrobert.codegpt.ui.textarea.lookup.action
import com.intellij.icons.AllIcons
import com.intellij.openapi.project.Project
import ee.carlrobert.codegpt.conversations.Conversation
import ee.carlrobert.codegpt.ui.textarea.UserInputPanel
import ee.carlrobert.codegpt.ui.textarea.header.tag.HistoryTagDetails
import javax.swing.Icon
class HistoryActionItem(
private val conversation: Conversation,
) : AbstractLookupActionItem() {
companion object {
fun getConversationTitle(conversation: Conversation): String {
return conversation.messages.firstOrNull()?.let { firstMessage ->
firstMessage.prompt?.take(60) ?: firstMessage.response?.take(60)
} ?: "Conversation"
}
}
override val displayName: String
get() = getConversationTitle(conversation)
override val icon: Icon
get() = AllIcons.General.Balloon
override fun execute(project: Project, userInputPanel: UserInputPanel) {
userInputPanel.addTag(
HistoryTagDetails(
conversationId = conversation.id,
title = displayName,
)
)
}
}

View file

@ -0,0 +1,58 @@
package ee.carlrobert.codegpt.ui.textarea.lookup.group
import com.intellij.codeInsight.lookup.impl.LookupImpl
import com.intellij.icons.AllIcons
import com.intellij.openapi.application.runInEdt
import ee.carlrobert.codegpt.CodeGPTBundle
import ee.carlrobert.codegpt.conversations.ConversationsState
import ee.carlrobert.codegpt.ui.textarea.lookup.DynamicLookupGroupItem
import ee.carlrobert.codegpt.ui.textarea.lookup.LookupActionItem
import ee.carlrobert.codegpt.ui.textarea.lookup.LookupUtil
import ee.carlrobert.codegpt.ui.textarea.lookup.action.HistoryActionItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class HistoryGroupItem : AbstractLookupGroupItem(), DynamicLookupGroupItem {
override val displayName: String =
CodeGPTBundle.get("suggestionGroupItem.history.displayName")
override val icon = AllIcons.Vcs.History
private val addedItems = mutableSetOf<String>()
override suspend fun getLookupItems(searchText: String): List<LookupActionItem> {
return ConversationsState.getInstance().conversations
.sortedByDescending { it.updatedOn }
.filter { conversation ->
if (searchText.isEmpty()) {
true
} else {
val title = HistoryActionItem.getConversationTitle(conversation)
title.contains(searchText, ignoreCase = true)
}
}
.map { HistoryActionItem(it) }
}
override suspend fun updateLookupList(lookup: LookupImpl, searchText: String) {
val filteredItems = getLookupItems(searchText)
withContext(Dispatchers.Default) {
if (searchText.isEmpty()) {
addedItems.clear()
}
filteredItems.forEach { item ->
val itemKey = item.displayName
if (!addedItems.contains(itemKey)) {
addedItems.add(itemKey)
runInEdt {
if (!lookup.isLookupDisposed) {
LookupUtil.addLookupItem(lookup, item)
}
}
}
}
}
}
}

View file

@ -310,6 +310,7 @@ userMessagePanel.persona.title=PERSONA
suggestionGroupItem.files.displayName=Files
suggestionGroupItem.folders.displayName=Folders
suggestionGroupItem.personas.displayName=Personas
suggestionGroupItem.history.displayName=History
suggestionGroupItem.docs.displayName=Docs
suggestionGroupItem.git.displayName=Git
suggestionGroupItem.mcp.displayName=MCP (soon)
@ -325,4 +326,4 @@ tagPopupMenuItem.closeAll=Close All Tags
tagPopupMenuItem.closeTagsToLeft=Close Tags to the Left
tagPopupMenuItem.closeTagsToRight=Close Tags to the Right
toolwindow.chat.loading=Generating response...
headerPanel.error.searchBlockNotMapped.title=Failed to Locate Search Block
headerPanel.error.searchBlockNotMapped.title=Failed to Locate Search Block

View file

@ -8,6 +8,7 @@ import ee.carlrobert.codegpt.settings.prompts.PromptsSettings
import ee.carlrobert.llm.client.openai.completion.OpenAIChatCompletionModel
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.groups.Tuple
import org.junit.jupiter.api.Assertions.assertThrows
import testsupport.IntegrationTest
class CompletionRequestProviderTest : IntegrationTest() {
@ -29,7 +30,7 @@ class CompletionRequestProviderTest : IntegrationTest() {
assertThat(request.messages)
.extracting("role", "content")
.containsExactly(
Tuple.tuple("system", "TEST_SYSTEM_PROMPT"),
Tuple.tuple("system", "TEST_SYSTEM_PROMPT\n"),
Tuple.tuple("user", "TEST_PROMPT"),
Tuple.tuple("assistant", firstMessage.response),
Tuple.tuple("user", "TEST_PROMPT"),
@ -55,7 +56,7 @@ class CompletionRequestProviderTest : IntegrationTest() {
assertThat(request.messages)
.extracting("role", "content")
.containsExactly(
Tuple.tuple("system", "TEST_SYSTEM_PROMPT"),
Tuple.tuple("system", "TEST_SYSTEM_PROMPT\n"),
Tuple.tuple("user", "FIRST_TEST_PROMPT"),
Tuple.tuple("assistant", firstMessage.response),
Tuple.tuple("user", "SECOND_TEST_PROMPT")
@ -84,7 +85,6 @@ class CompletionRequestProviderTest : IntegrationTest() {
private fun createDummyMessage(prompt: String, tokenSize: Int): Message {
val message = Message(prompt)
// 'zz' = 1 token, prompt = 6 tokens, 7 tokens per message (GPT-3),
message.response = "zz".repeat((tokenSize) - 6 - 7)
return message
}

View file

@ -1,6 +1,7 @@
package ee.carlrobert.codegpt.completions
import com.intellij.openapi.components.service
import ee.carlrobert.codegpt.completions.HuggingFaceModel
import ee.carlrobert.codegpt.completions.llama.PromptTemplate.LLAMA
import ee.carlrobert.codegpt.conversations.ConversationService
import ee.carlrobert.codegpt.conversations.message.Message
@ -34,7 +35,7 @@ class DefaultToolwindowChatCompletionRequestHandlerTest : IntegrationTest() {
.containsExactly(
"gpt-4o",
listOf(
mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT"),
mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT\n"),
mapOf("role" to "user", "content" to "TEST_PROMPT")
)
)
@ -117,7 +118,7 @@ class DefaultToolwindowChatCompletionRequestHandlerTest : IntegrationTest() {
.containsExactly(
HuggingFaceModel.LLAMA_3_8B_Q6_K.code,
listOf(
mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT"),
mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT\n"),
mapOf("role" to "user", "content" to "TEST_PROMPT")
)
)
@ -199,7 +200,7 @@ class DefaultToolwindowChatCompletionRequestHandlerTest : IntegrationTest() {
.containsExactly(
"TEST_MODEL",
listOf(
mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT"),
mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT\n"),
mapOf("role" to "user", "content" to "TEST_PROMPT")
)
)

View file

@ -44,7 +44,7 @@ class ChatToolWindowTabPanelTest : IntegrationTest() {
.containsExactly(
"gpt-4o",
listOf(
mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT"),
mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT\n"),
mapOf("role" to "user", "content" to "Hello!")
)
)
@ -116,7 +116,7 @@ class ChatToolWindowTabPanelTest : IntegrationTest() {
.containsExactly(
"gpt-4o",
listOf(
mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT"),
mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT\n"),
mapOf(
"role" to "user",
"content" to """
@ -211,7 +211,7 @@ class ChatToolWindowTabPanelTest : IntegrationTest() {
.containsExactly(
"gpt-4-vision-preview",
listOf(
mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT"),
mapOf("role" to "system", "content" to "TEST_SYSTEM_PROMPT\n"),
mapOf(
"role" to "user", "content" to listOf(
mapOf(

View file

@ -0,0 +1,204 @@
package ee.carlrobert.codegpt.ui.textarea
import com.intellij.openapi.components.service
import ee.carlrobert.codegpt.conversations.ConversationService
import ee.carlrobert.codegpt.conversations.ConversationsState
import ee.carlrobert.codegpt.conversations.message.Message
import ee.carlrobert.codegpt.ui.textarea.lookup.action.HistoryActionItem
import org.assertj.core.api.Assertions.assertThat
import testsupport.IntegrationTest
import java.util.*
class ConversationTagProcessorTest : IntegrationTest() {
private lateinit var conversationService: ConversationService
public override fun setUp() {
super.setUp()
conversationService = service<ConversationService>()
ConversationsState.getInstance().conversations.clear()
}
fun `test should format conversation with single message`() {
val conversation = conversationService.createConversation()
conversation.addMessage(Message("How do I create a REST API?").apply {
response = "You can create a REST API using frameworks like Spring Boot or Express.js."
})
val formatted = ConversationTagProcessor.formatConversation(conversation)
assertThat(formatted).contains("# History")
assertThat(formatted).contains("## Conversation: How do I create a REST API?")
assertThat(formatted).contains("**User**: How do I create a REST API?")
assertThat(formatted).contains("**Assistant**: You can create a REST API using frameworks like Spring Boot or Express.js.")
}
fun `test should format conversation with multiple messages`() {
val conversation = conversationService.createConversation()
conversation.addMessage(Message("What is Docker?").apply {
response = "Docker is a containerization platform."
})
conversation.addMessage(Message("How do I install Docker?").apply {
response = "You can install Docker from docker.com."
})
conversation.addMessage(Message("What are Docker containers?").apply {
response = "Containers are lightweight, portable environments."
})
val formatted = ConversationTagProcessor.formatConversation(conversation)
assertThat(formatted).contains("# History")
assertThat(formatted).contains("## Conversation: What is Docker?")
assertThat(formatted).contains("**User**: What is Docker?")
assertThat(formatted).contains("**Assistant**: Docker is a containerization platform.")
assertThat(formatted).contains("**User**: How do I install Docker?")
assertThat(formatted).contains("**Assistant**: You can install Docker from docker.com.")
assertThat(formatted).contains("**User**: What are Docker containers?")
assertThat(formatted).contains("**Assistant**: Containers are lightweight, portable environments.")
}
fun `test should handle null prompt in formatting`() {
val conversation = conversationService.createConversation()
conversation.addMessage(Message("").apply {
prompt = null
response = "This is a response without a prompt."
})
val formatted = ConversationTagProcessor.formatConversation(conversation)
assertThat(formatted).contains("# History")
assertThat(formatted).contains("**User**: null")
assertThat(formatted).contains("**Assistant**: This is a response without a prompt.")
}
fun `test should handle null response in formatting`() {
val conversation = conversationService.createConversation()
conversation.addMessage(Message("What is AI?").apply {
response = null
})
val formatted = ConversationTagProcessor.formatConversation(conversation)
assertThat(formatted).contains("# History")
assertThat(formatted).contains("**User**: What is AI?")
assertThat(formatted).contains("**Assistant**: null")
}
fun `test should use first message as conversation title`() {
val conversation = conversationService.createConversation()
conversation.addMessage(Message("First message").apply {
response = "First response"
})
conversation.addMessage(Message("Second message").apply {
response = "Second response"
})
val formatted = ConversationTagProcessor.formatConversation(conversation)
assertThat(formatted).contains("## Conversation: First message")
assertThat(formatted).doesNotContain("## Conversation: Second message")
}
fun `test should find current conversation by id`() {
val conversation = conversationService.startConversation()
conversation.addMessage(Message("Current conversation test").apply {
response = "This is the current conversation"
})
val foundConversation = ConversationTagProcessor.getConversation(conversation.id)
assertThat(foundConversation).isNotNull
assertThat(foundConversation!!.id).isEqualTo(conversation.id)
assertThat(foundConversation.messages).hasSize(1)
assertThat(foundConversation.messages[0].prompt).isEqualTo("Current conversation test")
}
fun `test should find stored conversation by id`() {
val conversation = conversationService.createConversation()
conversation.addMessage(Message("Stored conversation test").apply {
response = "This is a stored conversation"
})
conversationService.addConversation(conversation)
val foundConversation = ConversationTagProcessor.getConversation(conversation.id)
assertThat(foundConversation).isNotNull
assertThat(foundConversation!!.id).isEqualTo(conversation.id)
assertThat(foundConversation.messages).hasSize(1)
assertThat(foundConversation.messages[0].prompt).isEqualTo("Stored conversation test")
}
fun `test should prefer current conversation over stored when same id`() {
val storedConversation = conversationService.createConversation()
storedConversation.addMessage(Message("Stored version").apply {
response = "This is stored"
})
conversationService.addConversation(storedConversation)
val currentConversation = conversationService.startConversation()
currentConversation.id = storedConversation.id
currentConversation.addMessage(Message("Current version").apply {
response = "This is current"
})
val foundConversation = ConversationTagProcessor.getConversation(storedConversation.id)
assertThat(foundConversation).isNotNull
assertThat(foundConversation!!.messages[0].prompt).isEqualTo("Current version")
}
fun `test should return null for non-existent conversation`() {
val nonExistentId = UUID.randomUUID()
val foundConversation = ConversationTagProcessor.getConversation(nonExistentId)
assertThat(foundConversation).isNull()
}
fun `test should integrate with history action item`() {
val conversation = conversationService.createConversation()
conversation.addMessage(Message("Integration test prompt").apply {
response = "Integration test response"
})
conversationService.addConversation(conversation)
val historyActionItem = HistoryActionItem(conversation)
val foundConversation = ConversationTagProcessor.getConversation(conversation.id)
val conversationTitle = historyActionItem.displayName
val formatted = ConversationTagProcessor.formatConversation(foundConversation!!)
assertThat(conversationTitle).isEqualTo("Integration test prompt")
assertThat(formatted).contains("## Conversation: Integration test prompt")
assertThat(formatted).contains("**User**: Integration test prompt")
assertThat(formatted).contains("**Assistant**: Integration test response")
}
fun `test should handle empty message list`() {
val conversation = conversationService.createConversation()
val formatted = ConversationTagProcessor.formatConversation(conversation)
assertThat(formatted).contains("# History")
assertThat(formatted).contains("## Conversation: Conversation")
}
fun `test should handle long messages and truncate title`() {
val conversation = conversationService.createConversation()
val longPrompt =
"This is a very long prompt that contains a lot of text to test how the conversation formatter handles lengthy content. " +
"It should include all the text without truncating it, unlike the title which gets truncated to 60 characters."
val longResponse =
"This is an equally long response that provides detailed information about the question asked. " +
"The response should also be included in full without any truncation when formatting the conversation history."
conversation.addMessage(Message(longPrompt).apply {
response = longResponse
})
val formatted = ConversationTagProcessor.formatConversation(conversation)
assertThat(formatted).contains("# History")
assertThat(formatted).contains("**User**: $longPrompt")
assertThat(formatted).contains("**Assistant**: $longResponse")
val titleInFormatted = formatted.substringAfter("## Conversation: ").substringBefore("\n")
assertThat(titleInFormatted).hasSize(60)
}
}

View file

@ -0,0 +1,193 @@
package ee.carlrobert.codegpt.ui.textarea
import com.intellij.openapi.components.service
import ee.carlrobert.codegpt.conversations.ConversationService
import ee.carlrobert.codegpt.conversations.ConversationsState
import ee.carlrobert.codegpt.conversations.message.Message
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager
import ee.carlrobert.codegpt.ui.textarea.lookup.action.HistoryActionItem
import ee.carlrobert.codegpt.ui.textarea.lookup.group.HistoryGroupItem
import kotlinx.coroutines.runBlocking
import org.assertj.core.api.Assertions.assertThat
import testsupport.IntegrationTest
class HistorySearchIntegrationTest : IntegrationTest() {
private lateinit var conversationService: ConversationService
private lateinit var searchManager: SearchManager
private lateinit var historyGroupItem: HistoryGroupItem
public override fun setUp() {
super.setUp()
conversationService = service<ConversationService>()
ConversationsState.getInstance().conversations.clear()
val tagManager = TagManager(testRootDisposable)
searchManager = SearchManager(project, tagManager)
historyGroupItem = HistoryGroupItem()
}
fun `test should include history group in search manager default groups`() {
val defaultGroups = searchManager.getDefaultGroups()
assertThat(defaultGroups).anyMatch { it is HistoryGroupItem }
}
fun `test should include history results in global search`() {
createTestConversations()
val results = runBlocking { searchManager.performGlobalSearch("java") }
val historyResults = results.filterIsInstance<HistoryActionItem>()
assertThat(historyResults).hasSizeGreaterThan(0)
assertThat(historyResults.any { it.displayName.contains("Java", ignoreCase = true) }).isTrue()
}
fun `test should filter conversations by search terms`() {
ConversationsState.getInstance().conversations.clear()
createTestConversations()
val testCases = mapOf(
"java" to 3,
"python" to 1,
"javascript" to 2,
"programming" to 1,
"database" to 1,
"nonexistent" to 0
)
testCases.forEach { (searchTerm, expectedCount) ->
val results = runBlocking { historyGroupItem.getLookupItems(searchTerm) }
assertThat(results)
.withFailMessage("Search term '$searchTerm' should return $expectedCount results")
.hasSize(expectedCount)
}
}
fun `test should return all matching conversations for search term`() {
val conversations = listOf(
"Java programming basics" to "Learn Java fundamentals",
"Advanced Java concepts" to "Deep dive into Java",
"JavaScript vs Java comparison" to "Compare languages",
"Python programming" to "Learn Python basics"
)
conversations.forEach { (prompt, response) ->
val conversation = conversationService.createConversation()
conversation.addMessage(Message(prompt).apply { this.response = response })
conversationService.addConversation(conversation)
}
val javaResults = runBlocking { historyGroupItem.getLookupItems("java") }
assertThat(javaResults).hasSize(3)
val displayNames = javaResults.map { (it as HistoryActionItem).displayName }
displayNames.forEach { name ->
assertThat(name.lowercase()).contains("java")
}
}
fun `test should handle special characters in search`() {
val conversation = conversationService.createConversation()
conversation.addMessage(Message("What is C++ programming?").apply {
response = "C++ is a powerful programming language"
})
conversationService.addConversation(conversation)
val results = runBlocking { historyGroupItem.getLookupItems("c++") }
assertThat(results).hasSize(1)
assertThat((results[0] as HistoryActionItem).displayName).contains("C++")
}
fun `test should perform search efficiently with many conversations`() {
for (i in 1..100) {
val conversation = conversationService.createConversation()
val topic = when (i % 5) {
0 -> "Java"
1 -> "Python"
2 -> "JavaScript"
3 -> "Database"
else -> "General"
}
conversation.addMessage(Message("Question about $topic #$i").apply {
response = "Answer about $topic #$i"
})
conversationService.addConversation(conversation)
}
val startTime = System.currentTimeMillis()
val results = runBlocking { historyGroupItem.getLookupItems("java") }
val endTime = System.currentTimeMillis()
assertThat(results).hasSize(40)
assertThat(endTime - startTime).isLessThan(1000)
results.forEach { result ->
val displayName = (result as HistoryActionItem).displayName
assertThat(displayName.lowercase()).contains("java")
}
}
fun `test should handle empty and whitespace queries`() {
createTestConversations()
val emptyResults = runBlocking { historyGroupItem.getLookupItems("") }
val whitespaceResults = runBlocking { historyGroupItem.getLookupItems(" ") }
assertThat(emptyResults).hasSizeLessThanOrEqualTo(10)
assertThat(whitespaceResults).hasSizeLessThanOrEqualTo(emptyResults.size)
}
fun `test should search in conversation titles`() {
val conversation1 = conversationService.createConversation()
conversation1.addMessage(Message("How to use Docker containers?").apply {
response = "Docker containers are lightweight virtualization"
})
conversationService.addConversation(conversation1)
val conversation2 = conversationService.createConversation()
conversation2.addMessage(Message("What is virtualization?").apply {
response = "Virtualization allows running multiple Docker instances"
})
conversationService.addConversation(conversation2)
val dockerResults = runBlocking { historyGroupItem.getLookupItems("docker") }
val virtualizationResults = runBlocking { historyGroupItem.getLookupItems("virtualization") }
assertThat(dockerResults).hasSize(1)
assertThat((dockerResults[0] as HistoryActionItem).displayName).contains("Docker")
assertThat(virtualizationResults).hasSize(1)
assertThat((virtualizationResults[0] as HistoryActionItem).displayName).contains("virtualization")
}
fun `test should match history aliases in search manager`() {
val historyAliases = listOf("history", "hist", "h")
historyAliases.forEach { alias ->
val matches = searchManager.matchesAnyDefaultGroup(alias)
assertThat(matches)
.withFailMessage("Alias '$alias' should match default group names")
.isTrue()
}
}
fun `test should have correct properties for history group item`() {
assertThat(historyGroupItem.enabled).isTrue()
assertThat(historyGroupItem.displayName).isEqualTo("History")
assertThat(historyGroupItem.icon).isNotNull()
}
private fun createTestConversations() {
val testData = listOf(
"How to write Java code?" to "Use Java syntax and compile with javac",
"Python vs JavaScript comparison" to "Python is interpreted, JavaScript runs in browsers",
"What is database normalization?" to "Database normalization reduces redundancy",
"Best practices for programming" to "Write clean, readable, and testable code",
"JavaScript async/await tutorial" to "Use async/await for asynchronous programming"
)
testData.forEach { (prompt, response) ->
val conversation = conversationService.createConversation()
conversation.addMessage(Message(prompt).apply { this.response = response })
conversationService.addConversation(conversation)
}
}
}

View file

@ -0,0 +1,236 @@
package ee.carlrobert.codegpt.ui.textarea
import com.intellij.openapi.components.service
import ee.carlrobert.codegpt.conversations.ConversationService
import ee.carlrobert.codegpt.conversations.ConversationsState
import ee.carlrobert.codegpt.conversations.message.Message
import ee.carlrobert.codegpt.toolwindow.chat.MessageBuilder
import ee.carlrobert.codegpt.ui.textarea.header.tag.HistoryTagDetails
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager
import ee.carlrobert.codegpt.ui.textarea.lookup.action.HistoryActionItem
import ee.carlrobert.codegpt.ui.textarea.lookup.group.HistoryGroupItem
import kotlinx.coroutines.runBlocking
import org.assertj.core.api.Assertions.assertThat
import testsupport.IntegrationTest
import java.time.LocalDateTime
import java.util.*
class HistoryTagIntegrationTest : IntegrationTest() {
private lateinit var conversationService: ConversationService
public override fun setUp() {
super.setUp()
conversationService = service<ConversationService>()
// Clear any existing conversations
ConversationsState.getInstance().conversations.clear()
}
fun testShouldDisplayCorrectNameForHistoryActionItem() {
val conversation = conversationService.createConversation()
val message = Message("What is the capital of France?")
message.response = "The capital of France is Paris."
conversation.addMessage(message)
val historyActionItem = HistoryActionItem(conversation)
assertThat(historyActionItem.displayName).isEqualTo("What is the capital of France?")
}
fun testShouldTruncateLongPromptForDisplayName() {
val longPrompt = "This is a very long prompt that should be truncated when used as a conversation title because it exceeds the 60 character limit"
val conversation = conversationService.createConversation()
val message = Message(longPrompt)
message.response = "This is a response."
conversation.addMessage(message)
val historyActionItem = HistoryActionItem(conversation)
assertThat(historyActionItem.displayName).hasSize(60)
assertThat(historyActionItem.displayName).startsWith("This is a very long prompt that should be truncated when")
}
fun testShouldFallbackToResponseWhenPromptIsNull() {
val conversation = conversationService.createConversation()
val message = Message("")
message.prompt = null
message.response = "This is only a response without a prompt."
conversation.addMessage(message)
val historyActionItem = HistoryActionItem(conversation)
assertThat(historyActionItem.displayName).isEqualTo("This is only a response without a prompt.")
}
fun testShouldUseDefaultTitleWhenBothPromptAndResponseAreNull() {
val conversation = conversationService.createConversation()
val message = Message("")
message.prompt = null
message.response = null
conversation.addMessage(message)
val historyActionItem = HistoryActionItem(conversation)
assertThat(historyActionItem.displayName).isEqualTo("Conversation")
}
fun testShouldCreateCorrectTagDetails() {
val conversation = conversationService.createConversation()
val message = Message("What is the capital of France?")
message.response = "The capital of France is Paris."
conversation.addMessage(message)
val historyActionItem = HistoryActionItem(conversation)
val expectedTag = HistoryTagDetails(conversation.id, historyActionItem.displayName)
assertThat(expectedTag.conversationId).isEqualTo(conversation.id)
assertThat(expectedTag.title).isEqualTo("What is the capital of France?")
assertThat(historyActionItem.icon).isNotNull()
}
fun testShouldFilterConversationsBySearchText() {
val historyGroupItem = HistoryGroupItem()
val javaConversation = conversationService.createConversation()
javaConversation.addMessage(Message("How to write Java code?").apply { response = "Use Java syntax" })
conversationService.addConversation(javaConversation)
val pythonConversation = conversationService.createConversation()
pythonConversation.addMessage(Message("Python programming tutorial").apply { response = "Learn Python basics" })
conversationService.addConversation(pythonConversation)
val javaResults = runBlocking { historyGroupItem.getLookupItems("java") }
val pythonResults = runBlocking { historyGroupItem.getLookupItems("python") }
assertThat(javaResults).hasSize(1)
assertThat((javaResults[0] as HistoryActionItem).displayName).contains("Java")
assertThat(pythonResults).hasSize(1)
assertThat((pythonResults[0] as HistoryActionItem).displayName).contains("Python")
}
fun testShouldHandleEmptyConversationsList() {
val historyGroupItem = HistoryGroupItem()
val results = runBlocking { historyGroupItem.getLookupItems("") }
assertThat(results).isEmpty()
}
fun testShouldPerformCaseInsensitiveSearch() {
val historyGroupItem = HistoryGroupItem()
val conversation = conversationService.createConversation()
conversation.addMessage(Message("JavaScript Tutorial").apply { response = "Learn JS" })
conversationService.addConversation(conversation)
val upperCaseResults = runBlocking { historyGroupItem.getLookupItems("JAVASCRIPT") }
val lowerCaseResults = runBlocking { historyGroupItem.getLookupItems("javascript") }
val mixedCaseResults = runBlocking { historyGroupItem.getLookupItems("JavaScript") }
assertThat(upperCaseResults).hasSize(1)
assertThat(lowerCaseResults).hasSize(1)
assertThat(mixedCaseResults).hasSize(1)
}
fun testShouldIntegrateHistoryTagsInMessageBuilder() {
val existingConversation = conversationService.createConversation()
val existingMessage = Message("Previous question")
existingMessage.response = "Previous answer"
existingConversation.addMessage(existingMessage)
val historyTag = HistoryTagDetails(
conversationId = existingConversation.id,
title = "Previous conversation"
)
val message = MessageBuilder(project, "New question referencing history")
.withInlays(listOf(historyTag))
.build()
assertThat(message.prompt).isEqualTo("New question referencing history")
}
fun testShouldFormatConversationCorrectly() {
val conversation = conversationService.createConversation()
conversation.addMessage(Message("First question").apply { response = "First answer" })
conversation.addMessage(Message("Second question").apply { response = "Second answer" })
val formatted = ConversationTagProcessor.formatConversation(conversation)
assertThat(formatted).contains("# History")
assertThat(formatted).contains("## Conversation: First question")
assertThat(formatted).contains("**User**: First question")
assertThat(formatted).contains("**Assistant**: First answer")
assertThat(formatted).contains("**User**: Second question")
assertThat(formatted).contains("**Assistant**: Second answer")
}
fun testShouldFindConversationById() {
val conversation = conversationService.createConversation()
conversation.addMessage(Message("Test question").apply { response = "Test answer" })
conversationService.addConversation(conversation)
val foundConversation = ConversationTagProcessor.getConversation(conversation.id)
assertThat(foundConversation).isNotNull
assertThat(foundConversation!!.id).isEqualTo(conversation.id)
assertThat(foundConversation.messages).hasSize(1)
assertThat(foundConversation.messages[0].prompt).isEqualTo("Test question")
}
fun testShouldReturnNullForNonExistentConversation() {
val nonExistentId = UUID.randomUUID()
val foundConversation = ConversationTagProcessor.getConversation(nonExistentId)
assertThat(foundConversation).isNull()
}
fun testShouldIncludeHistoryGroupInSearchManager() {
val tagManager = TagManager(testRootDisposable)
val searchManager = SearchManager(project, tagManager)
val defaultGroups = searchManager.getDefaultGroups()
assertThat(defaultGroups).anyMatch { it is HistoryGroupItem }
}
fun testShouldIncludeHistoryAliasesInConstants() {
assertThat(PromptTextFieldConstants.DEFAULT_GROUP_NAMES)
.contains("history", "hist", "h")
}
fun testShouldImplementEqualsAndHashcodeCorrectlyForHistoryTagDetails() {
val conversationId = UUID.randomUUID()
val tag1 = HistoryTagDetails(conversationId, "Test conversation")
val tag2 = HistoryTagDetails(conversationId, "Test conversation")
val tag3 = HistoryTagDetails(UUID.randomUUID(), "Different conversation")
assertThat(tag1).isEqualTo(tag2)
assertThat(tag1).isNotEqualTo(tag3)
assertThat(tag1.hashCode()).isEqualTo(tag2.hashCode())
assertThat(tag1.hashCode()).isNotEqualTo(tag3.hashCode())
}
fun testShouldSortConversationsByUpdatedDateDescending() {
val historyGroupItem = HistoryGroupItem()
val now = LocalDateTime.now()
val oldConversation = conversationService.createConversation()
oldConversation.updatedOn = now.minusDays(3)
oldConversation.addMessage(Message("Old conversation").apply { response = "Old response" })
conversationService.addConversation(oldConversation)
val newestConversation = conversationService.createConversation()
newestConversation.updatedOn = now
newestConversation.addMessage(Message("Newest conversation").apply { response = "Newest response" })
conversationService.addConversation(newestConversation)
val middleConversation = conversationService.createConversation()
middleConversation.updatedOn = now.minusDays(1)
middleConversation.addMessage(Message("Middle conversation").apply { response = "Middle response" })
conversationService.addConversation(middleConversation)
val results = runBlocking { historyGroupItem.getLookupItems("") }
assertThat(results).hasSize(3)
val displayNames = results.map { (it as HistoryActionItem).displayName }
assertThat(displayNames[0]).isEqualTo("Newest conversation")
assertThat(displayNames[1]).isEqualTo("Middle conversation")
assertThat(displayNames[2]).isEqualTo("Old conversation")
}
}