Fixed diagnostics being broken + added diagnostics as tool for agent. Now it shows relevant files and can filter errors separately.

Fixed Custom OpenAI providers being broken in Agent mode. None worked previously, was streaming bug issues for both
This commit is contained in:
Roman Gromov 2026-03-13 23:10:18 +01:00
parent 1c37947bc2
commit 23241ec98b
No known key found for this signature in database
GPG key ID: 572929A609857F1E
24 changed files with 937 additions and 210 deletions

View file

@ -534,6 +534,13 @@ object AgentFactory {
hookManager = hookManager,
)
)
if (SubagentTool.DIAGNOSTICS in selected) tool(
DiagnosticsTool(
project = project,
sessionId = sessionId,
hookManager = hookManager,
)
)
if (SubagentTool.WEB_SEARCH in selected) tool(
WebSearchTool(
workingDirectory = project.basePath ?: System.getProperty("user.dir"),

View file

@ -23,6 +23,7 @@ import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import ee.carlrobert.codegpt.EncodingManager
import ee.carlrobert.codegpt.agent.clients.shouldStream
import ee.carlrobert.codegpt.agent.clients.shouldStreamCustomOpenAI
import ee.carlrobert.codegpt.agent.strategy.CODE_AGENT_COMPRESSION
import ee.carlrobert.codegpt.agent.strategy.HistoryCompressionConfig
import ee.carlrobert.codegpt.agent.strategy.SingleRunStrategyProvider
@ -34,7 +35,6 @@ import ee.carlrobert.codegpt.settings.hooks.HookManager
import ee.carlrobert.codegpt.settings.models.ModelSettings
import ee.carlrobert.codegpt.settings.service.FeatureType
import ee.carlrobert.codegpt.settings.service.ServiceType
import ee.carlrobert.codegpt.settings.service.custom.CustomServicesSettings
import ee.carlrobert.codegpt.settings.skills.SkillDiscoveryService
import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.BashPayload
import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.ToolApprovalRequest
@ -89,7 +89,7 @@ object ProxyAIAgent {
val modelSelection =
service<ModelSettings>().getModelSelectionForFeature(FeatureType.AGENT)
val skills = project.service<SkillDiscoveryService>().listSkills()
val stream = shouldStreamAgentToolLoop(project, provider)
val stream = shouldStreamAgentToolLoop(provider)
val projectInstructions = loadProjectInstructions(project.basePath)
val executor = AgentFactory.createExecutor(provider, events)
val pendingMessageQueue = pendingMessages.getOrPut(sessionId) { ArrayDeque() }
@ -268,18 +268,10 @@ object ProxyAIAgent {
}
private fun shouldStreamAgentToolLoop(
project: Project,
provider: ServiceType,
): Boolean {
return when (provider) {
ServiceType.CUSTOM_OPENAI -> {
val selectedServiceId =
project.service<ModelSettings>().getStoredModelForFeature(FeatureType.AGENT)
project.service<CustomServicesSettings>().state.services
.firstOrNull { it.id == selectedServiceId }?.chatCompletionSettings?.shouldStream()
?: false
}
ServiceType.CUSTOM_OPENAI -> shouldStreamCustomOpenAI(FeatureType.AGENT)
ServiceType.GOOGLE -> false
else -> true
}
@ -323,6 +315,13 @@ object ProxyAIAgent {
hookManager = hookManager,
)
)
tool(
DiagnosticsTool(
project = project,
sessionId = sessionId,
hookManager = hookManager,
)
)
tool(
WebSearchTool(
workingDirectory = workingDirectory,

View file

@ -4,6 +4,7 @@ enum class SubagentTool(val id: String, val displayName: String, val isWrite: Bo
READ("read", "Read", false),
TODO_WRITE("todowrite", "TodoWrite", false),
INTELLIJ_SEARCH("intellijsearch", "IntelliJSearch", false),
DIAGNOSTICS("diagnostics", "Diagnostics", false),
WEB_SEARCH("websearch", "WebSearch", false),
WEB_FETCH("webfetch", "WebFetch", false),
MCP("MCP", "MCP", false),

View file

@ -14,6 +14,7 @@ enum class ToolName(val id: String, val aliases: Set<String> = emptySet()) {
BASH_OUTPUT("BashOutput"),
KILL_SHELL("KillShell"),
INTELLIJ_SEARCH("IntelliJSearch"),
DIAGNOSTICS("Diagnostics"),
WEB_SEARCH("WebSearch"),
WEB_FETCH("WebFetch"),
MCP("MCP"),
@ -94,6 +95,13 @@ object ToolSpecs {
IntelliJSearchTool.Result.serializer()
)
)
register(
ToolSpec(
ToolName.DIAGNOSTICS,
DiagnosticsTool.Args.serializer(),
DiagnosticsTool.Result.serializer()
)
)
register(
ToolSpec(
ToolName.WEB_SEARCH,

View file

@ -193,7 +193,7 @@ class CustomOpenAILLMClient(
}
if (isResponsesApi) {
return serializeResponsesApiRequest(state, messages, model, tools)
return serializeResponsesApiRequest(state, messages, model, tools, toolChoice)
}
val customParams: CustomOpenAIParams = params.toCustomOpenAIParams(state)
@ -245,7 +245,8 @@ class CustomOpenAILLMClient(
state: CustomServiceChatCompletionSettingsState,
messages: List<OpenAIMessage>,
model: LLModel,
tools: List<OpenAITool>?
tools: List<OpenAITool>?,
toolChoice: OpenAIToolChoice?
): String {
val streamRequest = state.shouldStream()
@ -264,8 +265,9 @@ class CustomOpenAILLMClient(
}
put("model", JsonPrimitive(model.id))
if (!tools.isNullOrEmpty()) {
put("tools", json.encodeToJsonElement(ListSerializer(OpenAITool.serializer()), tools))
put("tools", JsonArray(tools.map { it.toResponsesApiToolJson() }))
}
toolChoice?.toResponsesApiToolChoiceJson()?.let { put("tool_choice", it) }
}.toString()
}
@ -418,10 +420,17 @@ class CustomOpenAILLMClient(
}
override fun decodeStreamingResponse(data: String): CustomOpenAIChatCompletionStreamResponse {
val payload = normalizeSsePayload(data)
?: return CustomOpenAIChatCompletionStreamResponse(
choices = emptyList(),
created = 0,
id = "",
model = ""
)
if (!isResponsesApi) {
return json.decodeFromString(data)
return json.decodeFromString(payload)
}
return adaptResponsesApiStreamEvent(data)
return adaptResponsesApiStreamEvent(payload)
}
override fun decodeResponse(data: String): CustomOpenAIChatCompletionResponse {
@ -784,6 +793,36 @@ internal fun renderCustomOpenAIPrompt(messages: List<OpenAIMessage>, json: Json)
}
}
internal fun OpenAITool.toResponsesApiToolJson(): JsonObject {
return buildJsonObject {
put("type", JsonPrimitive("function"))
put("name", JsonPrimitive(function.name))
put(
"parameters",
function.parameters ?: buildJsonObject {
put("type", JsonPrimitive("object"))
putJsonObject("properties") {}
putJsonArray("required") {}
}
)
function.strict?.let { put("strict", JsonPrimitive(it)) }
function.description?.takeIf { it.isNotBlank() }?.let {
put("description", JsonPrimitive(it))
}
}
}
internal fun OpenAIToolChoice.toResponsesApiToolChoiceJson(): JsonElement {
return when (this) {
is OpenAIToolChoice.Function -> buildJsonObject {
put("type", JsonPrimitive("function"))
put("name", JsonPrimitive(function.name))
}
else -> JsonPrimitive(toString())
}
}
internal object CustomOpenAIChatCompletionRequestSerializer :
CustomOpenAIAdditionalPropertiesFlatteningSerializer(CustomOpenAIChatCompletionRequest.serializer())

View file

@ -0,0 +1,14 @@
package ee.carlrobert.codegpt.agent.clients
import com.intellij.openapi.components.service
import ee.carlrobert.codegpt.settings.service.FeatureType
import ee.carlrobert.codegpt.settings.service.custom.CustomServicesSettings
internal fun shouldStreamCustomOpenAI(featureType: FeatureType): Boolean {
return runCatching {
service<CustomServicesSettings>()
.customServiceStateForFeatureType(featureType)
.chatCompletionSettings
.shouldStream()
}.getOrDefault(false)
}

View file

@ -180,8 +180,11 @@ public class ProxyAILLMClient(
}
}
override fun decodeStreamingResponse(data: String): ProxyAIChatCompletionStreamResponse =
json.decodeFromString(data)
override fun decodeStreamingResponse(data: String): ProxyAIChatCompletionStreamResponse {
val payload = normalizeSsePayload(data)
?: return ProxyAIChatCompletionStreamResponse()
return json.decodeFromString(payload)
}
override fun decodeResponse(data: String): ProxyAIChatCompletionResponse =
json.decodeFromString(data)

View file

@ -0,0 +1,31 @@
package ee.carlrobert.codegpt.agent.clients
internal fun normalizeSsePayload(rawData: String): String? {
val normalized = rawData
.replace("\r\n", "\n")
.replace('\r', '\n')
.trim()
if (normalized.isEmpty()) {
return null
}
val dataLines = normalized.lineSequence()
.mapNotNull { line ->
val markerIndex = line.indexOf("data:")
if (markerIndex >= 0) {
line.substring(markerIndex + "data:".length).trimStart()
} else {
null
}
}
.filter { it.isNotEmpty() }
.toList()
val payload = if (dataLines.isNotEmpty()) {
dataLines.joinToString("\n")
} else {
normalized
}
return payload.takeUnless { it.isBlank() || it == "[DONE]" }
}

View file

@ -0,0 +1,123 @@
package ee.carlrobert.codegpt.agent.tools
import ai.koog.agents.core.tools.annotations.LLMDescription
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import ee.carlrobert.codegpt.diagnostics.DiagnosticsFilter
import ee.carlrobert.codegpt.diagnostics.ProjectDiagnosticsService
import ee.carlrobert.codegpt.settings.ProxyAISettingsService
import ee.carlrobert.codegpt.settings.ToolPermissionPolicy
import ee.carlrobert.codegpt.settings.hooks.HookManager
import ee.carlrobert.codegpt.tokens.truncateToolResult
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
class DiagnosticsTool(
private val project: Project,
private val sessionId: String,
private val hookManager: HookManager,
) : BaseTool<DiagnosticsTool.Args, DiagnosticsTool.Result>(
workingDirectory = project.basePath ?: System.getProperty("user.dir"),
argsSerializer = Args.serializer(),
resultSerializer = Result.serializer(),
name = "Diagnostics",
description = """
Reads the IDE's current diagnostics for a specific file.
Use this tool when you need compiler or inspection diagnostics for one file.
- The file_path parameter must be an absolute path.
- filter='errors_only' returns only errors.
- filter='all' returns errors, warnings, weak warnings, and info diagnostics.
- Results reflect diagnostics currently available in the IDE for that file.
""".trimIndent(),
argsClass = Args::class,
resultClass = Result::class,
hookManager = hookManager,
sessionId = sessionId,
) {
@Serializable
data class Args(
@property:LLMDescription(
"The absolute path to the file to inspect. Must be an absolute path."
)
@SerialName("file_path")
val filePath: String,
@property:LLMDescription(
"Diagnostics filter: 'errors_only' for errors only, or 'all' for all diagnostics."
)
val filter: DiagnosticsFilter = DiagnosticsFilter.ERRORS_ONLY
)
@Serializable
data class Result(
@SerialName("file_path")
val filePath: String,
val filter: DiagnosticsFilter,
@SerialName("diagnostic_count")
val diagnosticCount: Int = 0,
val output: String = "",
val error: String? = null
)
override suspend fun doExecute(args: Args): Result {
val settingsService = project.service<ProxyAISettingsService>()
val decision = settingsService.evaluateToolPermission(this, args.filePath)
if (decision == ToolPermissionPolicy.Decision.DENY) {
return Result(
filePath = args.filePath,
filter = args.filter,
error = "Access denied by permissions.deny for Diagnostics"
)
}
if (settingsService.hasAllowRulesForTool("Diagnostics")
&& decision != ToolPermissionPolicy.Decision.ALLOW
) {
return Result(
filePath = args.filePath,
filter = args.filter,
error = "Access denied by permissions.allow for Diagnostics"
)
}
if (settingsService.isPathIgnored(args.filePath)) {
return Result(
filePath = args.filePath,
filter = args.filter,
error = "File not found: ${args.filePath}"
)
}
val diagnosticsService = project.service<ProjectDiagnosticsService>()
val virtualFile = diagnosticsService.findVirtualFile(args.filePath)
?: return Result(
filePath = args.filePath,
filter = args.filter,
error = "File not found: ${args.filePath}"
)
val report = diagnosticsService.collect(virtualFile, args.filter)
return Result(
filePath = args.filePath,
filter = args.filter,
diagnosticCount = report.diagnosticCount,
output = report.content.ifBlank { args.filter.emptyMessage() },
error = report.error
)
}
override fun createDeniedResult(originalArgs: Args, deniedReason: String): Result {
return Result(
filePath = originalArgs.filePath,
filter = originalArgs.filter,
error = deniedReason
)
}
override fun encodeResultToString(result: Result): String {
if (result.error != null) {
return "Failed to read diagnostics for '${result.filePath}': ${result.error}"
.truncateToolResult()
}
return result.output.truncateToolResult()
}
}

View file

@ -8,21 +8,19 @@ import ai.koog.agents.features.eventHandler.feature.handleEvents
import ai.koog.agents.features.tokenizer.feature.MessageTokenizer
import ai.koog.prompt.dsl.prompt
import ai.koog.prompt.tokenizer.Tokenizer
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import ee.carlrobert.codegpt.EncodingManager
import ee.carlrobert.codegpt.agent.AgentEvents
import ee.carlrobert.codegpt.agent.MessageWithContext
import ee.carlrobert.codegpt.agent.clients.shouldStream
import ee.carlrobert.codegpt.agent.clients.shouldStreamCustomOpenAI
import ee.carlrobert.codegpt.agent.strategy.CODE_AGENT_COMPRESSION
import ee.carlrobert.codegpt.agent.strategy.HistoryCompressionConfig
import ee.carlrobert.codegpt.agent.strategy.SingleRunStrategyProvider
import ee.carlrobert.codegpt.mcp.McpTool
import ee.carlrobert.codegpt.mcp.McpToolAliasResolver
import ee.carlrobert.codegpt.mcp.McpToolCallHandler
import ee.carlrobert.codegpt.settings.models.ModelSettings
import ee.carlrobert.codegpt.settings.service.ServiceType
import ee.carlrobert.codegpt.settings.service.custom.CustomServicesSettings
import ee.carlrobert.codegpt.util.ReasoningFrameTextAdapter
import kotlinx.coroutines.*
import java.util.*
@ -51,7 +49,7 @@ internal object AgentCompletionRunner : CompletionRunner {
val jobRef = AtomicReference<Job?>()
val messageBuilder = StringBuilder()
val project = request.callParameters.project!!
val stream = shouldStreamAgentToolLoop(project, request)
val stream = shouldStreamAgentToolLoop(request)
val toolCallHandler = project.let { McpToolCallHandler.getInstance(it) }
val toolRegistry = createChatToolRegistry(
callParameters = request.callParameters,
@ -176,21 +174,13 @@ internal object AgentCompletionRunner : CompletionRunner {
}
internal fun shouldStreamAgentToolLoop(
project: Project,
request: CompletionRunnerRequest.Chat,
): Boolean {
val provider = request.serviceType
return when (provider) {
ServiceType.CUSTOM_OPENAI -> {
val selectedServiceId = project.service<ModelSettings>()
.getStoredModelForFeature(request.callParameters.featureType)
project.service<CustomServicesSettings>().state.services
.firstOrNull { it.id == selectedServiceId }
?.chatCompletionSettings
?.shouldStream()
?: false
}
ServiceType.CUSTOM_OPENAI -> shouldStreamCustomOpenAI(
request.callParameters.featureType
)
ServiceType.GOOGLE -> false
else -> true
}

View file

@ -0,0 +1,231 @@
package ee.carlrobert.codegpt.diagnostics
import com.intellij.codeInsight.daemon.impl.DaemonCodeAnalyzerImpl
import com.intellij.codeInsight.daemon.impl.HighlightInfo
import com.intellij.lang.annotation.HighlightSeverity
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.Service
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.project.DumbService
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.text.StringUtil
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.PsiManager
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
enum class DiagnosticsFilter(val displayName: String) {
@SerialName("errors_only")
ERRORS_ONLY("Errors only"),
@SerialName("all")
ALL("All");
fun includes(severity: HighlightSeverity): Boolean {
return when (this) {
ERRORS_ONLY -> severity == HighlightSeverity.ERROR
ALL -> severity == HighlightSeverity.ERROR ||
severity == HighlightSeverity.WARNING ||
severity == HighlightSeverity.WEAK_WARNING ||
severity == HighlightSeverity.INFORMATION
}
}
fun minimumSeverity(): HighlightSeverity {
return when (this) {
ERRORS_ONLY -> HighlightSeverity.ERROR
ALL -> HighlightSeverity.INFORMATION
}
}
fun emptyMessage(): String {
return when (this) {
ERRORS_ONLY -> "No errors found."
ALL -> "No diagnostics found."
}
}
}
data class DiagnosticsReport(
val filePath: String,
val filter: DiagnosticsFilter,
val content: String = "",
val diagnosticCount: Int = 0,
val error: String? = null
) {
val hasDiagnostics: Boolean
get() = error == null && diagnosticCount > 0
}
@Service(Service.Level.PROJECT)
class ProjectDiagnosticsService(
private val project: Project
) {
fun findVirtualFile(filePath: String): VirtualFile? {
val normalizedPath = filePath.replace('\\', '/')
val fileSystem = LocalFileSystem.getInstance()
return fileSystem.findFileByPath(normalizedPath)
?: fileSystem.refreshAndFindFileByPath(normalizedPath)
}
fun collect(
virtualFile: VirtualFile,
filter: DiagnosticsFilter = DiagnosticsFilter.ALL
): DiagnosticsReport {
return try {
var result = DiagnosticsReport(
filePath = virtualFile.path,
filter = filter
)
ApplicationManager.getApplication().invokeAndWait {
result = ApplicationManager.getApplication().runWriteAction<DiagnosticsReport> {
DumbService.getInstance(project).runReadActionInSmartMode<DiagnosticsReport> {
val document = FileDocumentManager.getInstance().getDocument(virtualFile)
?: return@runReadActionInSmartMode DiagnosticsReport(
filePath = virtualFile.path,
filter = filter,
error = "No document found for file."
)
PsiDocumentManager.getInstance(project).commitDocument(document)
val psiFile = PsiManager.getInstance(project).findFile(virtualFile)
?: return@runReadActionInSmartMode DiagnosticsReport(
filePath = virtualFile.path,
filter = filter,
error = "No PSI file found for: ${virtualFile.path}"
)
val rangeHighlights = DaemonCodeAnalyzerImpl.getHighlights(
document,
filter.minimumSeverity(),
project
)
val fileLevel = getFileLevelHighlights(psiFile)
val highlights = (rangeHighlights.asSequence() + fileLevel.asSequence())
.filter { filter.includes(it.severity) }
.mapNotNull { highlight ->
extractMessage(highlight)?.let { message ->
DiagnosticEntry(highlight, message)
}
}
.distinctBy { Triple(it.message, it.highlight.startOffset, it.highlight.severity) }
.sortedWith(
compareBy<DiagnosticEntry>(
{ severityOrder(it.highlight.severity) },
{ it.highlight.startOffset.coerceAtLeast(0) }
)
)
.toList()
if (highlights.isEmpty()) {
return@runReadActionInSmartMode DiagnosticsReport(
filePath = virtualFile.path,
filter = filter
)
}
val maxItems = 200
val overflow = (highlights.size - maxItems).coerceAtLeast(0)
val shown = highlights.take(maxItems)
val content = buildString {
append("File: ${virtualFile.name}\n")
append("Path: ${virtualFile.path}\n")
append("Filter: ${filter.displayName}\n\n")
shown.forEach { entry ->
val info = entry.highlight
val startOffset = info.startOffset.coerceIn(0, document.textLength)
val lineColText =
if (info.startOffset >= 0 && document.textLength > 0) {
val line = document.getLineNumber(startOffset) + 1
val col =
startOffset - document.getLineStartOffset(line - 1) + 1
"line $line, col $col"
} else {
"file-level"
}
append("- [${severityLabel(info.severity)}] $lineColText: ${entry.message}\n")
}
if (overflow > 0) {
append("... ($overflow more not shown)\n")
}
}
DiagnosticsReport(
filePath = virtualFile.path,
filter = filter,
content = content,
diagnosticCount = highlights.size
)
}
}
}
result
} catch (e: Exception) {
DiagnosticsReport(
filePath = virtualFile.path,
filter = filter,
error = "Error retrieving diagnostics: ${e.message}"
)
}
}
private fun getFileLevelHighlights(psiFile: com.intellij.psi.PsiFile): List<HighlightInfo> {
return try {
val method = DaemonCodeAnalyzerImpl::class.java.methods.firstOrNull {
it.name == "getFileLevelHighlights" && it.parameterCount == 2
}
if (method != null) {
@Suppress("UNCHECKED_CAST")
method.invoke(null, project, psiFile) as? List<HighlightInfo> ?: emptyList()
} else {
emptyList()
}
} catch (_: Throwable) {
emptyList()
}
}
private fun extractMessage(info: HighlightInfo): String? {
val rawMessage = info.description ?: info.toolTip ?: ""
return StringUtil.removeHtmlTags(rawMessage, false)
.trim()
.takeIf { it.isNotBlank() }
}
private fun severityLabel(severity: HighlightSeverity): String {
return when (severity) {
HighlightSeverity.ERROR -> "ERROR"
HighlightSeverity.WARNING -> "WARNING"
HighlightSeverity.WEAK_WARNING -> "WEAK_WARNING"
HighlightSeverity.INFORMATION -> "INFO"
else -> severity.toString()
}
}
private fun severityOrder(severity: HighlightSeverity): Int {
return when (severity) {
HighlightSeverity.ERROR -> 0
HighlightSeverity.WARNING -> 1
HighlightSeverity.WEAK_WARNING -> 2
HighlightSeverity.INFORMATION -> 3
else -> 4
}
}
private data class DiagnosticEntry(
val highlight: HighlightInfo,
val message: String
)
}

View file

@ -795,13 +795,18 @@ class InlineEditInlay(private var editor: Editor) : Disposable {
private fun collectDiagnosticsInfo(): String? {
val tags: Set<TagDetails> = tagManager.getTags()
val diagnosticsTag =
tags.firstOrNull { it.selected && it is DiagnosticsTagDetails } as? DiagnosticsTagDetails
?: return null
val diagnosticsTags = tags
.filter { it.selected && it is DiagnosticsTagDetails }
.filterIsInstance<DiagnosticsTagDetails>()
if (diagnosticsTags.isEmpty()) {
return null
}
val processor = TagProcessorFactory.getProcessor(project, diagnosticsTag)
val stringBuilder = StringBuilder()
processor.process(Message("", ""), stringBuilder)
diagnosticsTags.forEach { diagnosticsTag ->
val processor = TagProcessorFactory.getProcessor(project, diagnosticsTag)
processor.process(Message("", ""), stringBuilder)
}
return stringBuilder.toString().takeIf { it.isNotBlank() }
}
}

View file

@ -8,7 +8,6 @@ import ee.carlrobert.codegpt.settings.service.FeatureType
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager
import ee.carlrobert.codegpt.ui.textarea.lookup.LookupActionItem
import ee.carlrobert.codegpt.ui.textarea.lookup.LookupGroupItem
import ee.carlrobert.codegpt.ui.textarea.lookup.action.DiagnosticsActionItem
import ee.carlrobert.codegpt.ui.textarea.lookup.action.ImageActionItem
import ee.carlrobert.codegpt.ui.textarea.lookup.action.WebActionItem
import ee.carlrobert.codegpt.ui.textarea.lookup.action.files.IncludeOpenFilesActionItem
@ -44,7 +43,7 @@ class SearchManager(
FoldersGroupItem(project, tagManager),
if (GitFeatureAvailability.isAvailable) GitGroupItem(project) else null,
HistoryGroupItem(),
DiagnosticsActionItem(tagManager)
DiagnosticsGroupItem(tagManager)
).filter { it.enabled }
private fun getAgentGroups() = listOfNotNull(
@ -52,6 +51,7 @@ class SearchManager(
FoldersGroupItem(project, tagManager),
if (GitFeatureAvailability.isAvailable) GitGroupItem(project) else null,
MCPGroupItem(tagManager, FeatureType.AGENT),
DiagnosticsGroupItem(tagManager),
ImageActionItem(project, tagManager)
).filter { it.enabled }
@ -62,7 +62,7 @@ class SearchManager(
HistoryGroupItem(),
PersonasGroupItem(tagManager),
MCPGroupItem(tagManager, featureType ?: FeatureType.CHAT),
DiagnosticsActionItem(tagManager),
DiagnosticsGroupItem(tagManager),
WebActionItem(tagManager),
ImageActionItem(project, tagManager)
).filter { it.enabled }

View file

@ -1,24 +1,16 @@
package ee.carlrobert.codegpt.ui.textarea
import com.intellij.codeInsight.daemon.impl.DaemonCodeAnalyzerImpl
import com.intellij.codeInsight.daemon.impl.HighlightInfo
import com.intellij.lang.annotation.HighlightSeverity
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.runReadAction
import com.intellij.openapi.components.service
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.progress.ProgressManager
import com.intellij.openapi.project.DumbService
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.text.StringUtil
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.PsiManager
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.diagnostics.ProjectDiagnosticsService
import ee.carlrobert.codegpt.settings.ProxyAISettingsService
import ee.carlrobert.codegpt.ui.textarea.header.tag.*
import ee.carlrobert.codegpt.ui.textarea.lookup.action.HistoryActionItem
@ -270,118 +262,17 @@ class DiagnosticsTagProcessor(
private val project: Project,
private val tagDetails: DiagnosticsTagDetails,
) : TagProcessor {
private val diagnosticsService = project.service<ProjectDiagnosticsService>()
override fun process(message: Message, promptBuilder: StringBuilder) {
val diagnostics = diagnosticsService.collect(tagDetails.virtualFile, tagDetails.filter)
if (diagnostics.content.isBlank() && diagnostics.error == null) {
return
}
promptBuilder
.append("\n## Current File Problems\n")
.append(getDiagnosticsString(project, tagDetails.virtualFile))
.append("\n## ${tagDetails.virtualFile.name} Problems (${tagDetails.filter.displayName})\n")
.append(diagnostics.error ?: diagnostics.content)
.append("\n")
}
private fun getDiagnosticsString(project: Project, virtualFile: VirtualFile): String {
return try {
var result = ""
ApplicationManager.getApplication().invokeAndWait {
result = ApplicationManager.getApplication().runWriteAction<String> {
DumbService.getInstance(project).runReadActionInSmartMode<String> {
val document = FileDocumentManager.getInstance().getDocument(virtualFile)
?: return@runReadActionInSmartMode "No document found for file"
PsiDocumentManager.getInstance(project).commitDocument(document)
val psiManager = PsiManager.getInstance(project)
val psiFile = psiManager.findFile(virtualFile)
?: return@runReadActionInSmartMode "No PSI file found for: ${virtualFile.path}"
val rangeHighlights =
DaemonCodeAnalyzerImpl.getHighlights(
document,
HighlightSeverity.WEAK_WARNING,
project
)
// TODO: Find a better solution
val fileLevel: List<HighlightInfo> = try {
val method = DaemonCodeAnalyzerImpl::class.java.methods.firstOrNull {
it.name == "getFileLevelHighlights" && it.parameterCount == 2
}
if (method != null) {
@Suppress("UNCHECKED_CAST")
method.invoke(null, project, psiFile) as? List<HighlightInfo>
?: emptyList()
} else {
emptyList()
}
} catch (_: Throwable) {
emptyList()
}
val highlights = (rangeHighlights.asSequence() + fileLevel.asSequence())
.distinctBy { Triple(it.description, it.startOffset, it.severity) }
.sortedWith(
compareBy<HighlightInfo>(
{ severityOrder(it.severity) },
{ it.startOffset.coerceAtLeast(0) }
)
)
.toList()
if (highlights.isEmpty()) {
return@runReadActionInSmartMode ""
}
val maxItems = 200
val overflow = (highlights.size - maxItems).coerceAtLeast(0)
val shown = highlights.take(maxItems)
buildString {
append("File: ${virtualFile.name}\n")
append("Path: ${virtualFile.path}\n\n")
shown.forEach { info ->
val startOffset = info.startOffset.coerceIn(0, document.textLength)
val lineColText =
if (info.startOffset >= 0 && document.textLength > 0) {
val line = document.getLineNumber(startOffset) + 1
val col =
startOffset - document.getLineStartOffset(line - 1) + 1
"line $line, col $col"
} else {
"file-level"
}
val rawMessage = info.description ?: info.toolTip ?: ""
val message = StringUtil.removeHtmlTags(rawMessage, false).trim()
val severityLabel = when (info.severity) {
HighlightSeverity.ERROR -> "ERROR"
HighlightSeverity.WARNING -> "WARNING"
HighlightSeverity.WEAK_WARNING -> "WEAK_WARNING"
HighlightSeverity.INFORMATION -> "INFO"
else -> info.severity.toString()
}
append("- [$severityLabel] $lineColText: $message\n")
}
if (overflow > 0) {
append("... ($overflow more not shown)\n")
}
}
}
}
}
result
} catch (e: Exception) {
"Error retrieving diagnostics: ${e.message}"
}
}
private fun severityOrder(severity: HighlightSeverity): Int {
return when (severity) {
HighlightSeverity.ERROR -> 0
HighlightSeverity.WARNING -> 1
HighlightSeverity.WEAK_WARNING -> 2
HighlightSeverity.INFORMATION -> 3
else -> 4
}
}
}

View file

@ -5,6 +5,7 @@ import com.intellij.openapi.editor.SelectionModel
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.ui.JBColor
import ee.carlrobert.codegpt.Icons
import ee.carlrobert.codegpt.diagnostics.DiagnosticsFilter
import ee.carlrobert.codegpt.mcp.ConnectionStatus
import ee.carlrobert.codegpt.mcp.McpResource
import ee.carlrobert.codegpt.mcp.McpTool
@ -304,7 +305,9 @@ class CodeAnalyzeTagDetails : TagDetails("Code Analyze", AllIcons.Actions.Depend
override fun getTooltipText(): String? = null
}
data class DiagnosticsTagDetails(val virtualFile: VirtualFile) :
TagDetails("${virtualFile.name} Problems", AllIcons.General.InspectionsEye) {
override fun getTooltipText(): String = virtualFile.path
data class DiagnosticsTagDetails(
val virtualFile: VirtualFile,
val filter: DiagnosticsFilter = DiagnosticsFilter.ALL
) : TagDetails("${virtualFile.name} Problems (${filter.displayName})", AllIcons.General.InspectionsEye) {
override fun getTooltipText(): String = "${virtualFile.path} (${filter.displayName})"
}

View file

@ -1,41 +0,0 @@
package ee.carlrobert.codegpt.ui.textarea.lookup.action
import com.intellij.icons.AllIcons
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFile
import ee.carlrobert.codegpt.ui.textarea.UserInputPanel
import ee.carlrobert.codegpt.ui.textarea.header.tag.DiagnosticsTagDetails
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.TagManager
import ee.carlrobert.codegpt.util.EditorUtil
class DiagnosticsActionItem(
private val tagManager: TagManager
) : AbstractLookupActionItem() {
override val displayName: String = "Diagnostics"
override val icon = AllIcons.General.InspectionsEye
override val enabled: Boolean
get() = tagManager.getTags().none { it is DiagnosticsTagDetails } &&
tagManager.getTags().any { it is FileTagDetails || it is EditorTagDetails }
override fun execute(project: Project, userInputPanel: UserInputPanel) {
val virtualFile = findVirtualFile(project)
virtualFile?.let { file ->
userInputPanel.addTag(DiagnosticsTagDetails(file))
}
}
private fun findVirtualFile(project: Project): VirtualFile? {
val existingFile = tagManager.getTags()
.firstNotNullOfOrNull { tag ->
when (tag) {
is FileTagDetails -> tag.virtualFile
is EditorTagDetails -> tag.virtualFile
else -> null
}
}
return existingFile ?: EditorUtil.getSelectedEditor(project)?.virtualFile
}
}

View file

@ -0,0 +1,24 @@
package ee.carlrobert.codegpt.ui.textarea.lookup.action
import com.intellij.openapi.vfs.VirtualFile
import ee.carlrobert.codegpt.ui.textarea.header.tag.EditorSelectionTagDetails
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.SelectionTagDetails
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagDetails
internal fun selectedContextFiles(tags: Collection<TagDetails>): List<VirtualFile> {
return tags.asSequence()
.filter { it.selected }
.mapNotNull { tag ->
when (tag) {
is FileTagDetails -> tag.virtualFile
is EditorTagDetails -> tag.virtualFile
is SelectionTagDetails -> tag.virtualFile
is EditorSelectionTagDetails -> tag.virtualFile
else -> null
}
}
.distinctBy { it.path }
.toList()
}

View file

@ -0,0 +1,57 @@
package ee.carlrobert.codegpt.ui.textarea.lookup.action
import com.intellij.icons.AllIcons
import com.intellij.notification.NotificationType
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import ee.carlrobert.codegpt.diagnostics.DiagnosticsFilter
import ee.carlrobert.codegpt.diagnostics.ProjectDiagnosticsService
import ee.carlrobert.codegpt.ui.OverlayUtil
import ee.carlrobert.codegpt.ui.textarea.UserInputPanel
import ee.carlrobert.codegpt.ui.textarea.header.tag.DiagnosticsTagDetails
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager
class DiagnosticsFilterActionItem(
private val tagManager: TagManager,
private val filter: DiagnosticsFilter
) : AbstractLookupActionItem() {
override val displayName: String = filter.displayName
override val icon = AllIcons.General.InspectionsEye
override fun execute(project: Project, userInputPanel: UserInputPanel) {
val diagnosticsService = project.service<ProjectDiagnosticsService>()
val files = selectedContextFiles(userInputPanel.getSelectedTags())
var matched = false
files.forEach { virtualFile ->
val diagnostics = diagnosticsService.collect(virtualFile, filter)
if (!diagnostics.hasDiagnostics) {
return@forEach
}
matched = true
val newTag = DiagnosticsTagDetails(virtualFile, filter)
val existing = tagManager.getTags()
.filterIsInstance<DiagnosticsTagDetails>()
.firstOrNull { it.virtualFile == virtualFile }
when {
existing == null -> {
userInputPanel.addTag(newTag)
}
existing != newTag -> {
tagManager.updateTag(existing, newTag)
}
}
}
if (!matched) {
OverlayUtil.showNotification(
filter.emptyMessage().removeSuffix(".") + " in selected context files.",
NotificationType.INFORMATION
)
}
}
}

View file

@ -0,0 +1,27 @@
package ee.carlrobert.codegpt.ui.textarea.lookup.group
import com.intellij.icons.AllIcons
import ee.carlrobert.codegpt.diagnostics.DiagnosticsFilter
import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager
import ee.carlrobert.codegpt.ui.textarea.lookup.LookupActionItem
import ee.carlrobert.codegpt.ui.textarea.lookup.action.DiagnosticsFilterActionItem
import ee.carlrobert.codegpt.ui.textarea.lookup.action.selectedContextFiles
class DiagnosticsGroupItem(
private val tagManager: TagManager
) : AbstractLookupGroupItem() {
override val displayName: String = "Diagnostics"
override val icon = AllIcons.General.InspectionsEye
override val enabled: Boolean
get() = selectedContextFiles(tagManager.getTags()).isNotEmpty()
override suspend fun getLookupItems(searchText: String): List<LookupActionItem> {
return listOf(
DiagnosticsFilterActionItem(tagManager, DiagnosticsFilter.ERRORS_ONLY),
DiagnosticsFilterActionItem(tagManager, DiagnosticsFilter.ALL)
).filter {
searchText.isEmpty() || it.displayName.contains(searchText, ignoreCase = true)
}
}
}

View file

@ -4,6 +4,7 @@ import ai.koog.agents.snapshot.providers.file.JVMFilePersistenceStorageProvider
import com.intellij.openapi.components.service
import ee.carlrobert.codegpt.credentials.CredentialsStore.CredentialKey.*
import ee.carlrobert.codegpt.credentials.CredentialsStore.setCredential
import ee.carlrobert.codegpt.agent.clients.shouldStreamCustomOpenAI
import ee.carlrobert.codegpt.settings.models.ModelCatalog
import ee.carlrobert.codegpt.settings.models.ModelSettings
import ee.carlrobert.codegpt.settings.service.FeatureType
@ -174,6 +175,60 @@ class AgentProviderIntegrationTest : IntegrationTest() {
assertThat(result.events.text.toString()).isEqualTo("Hello from Custom OpenAI")
}
fun testCustomOpenAIAgentStreamsWhenStoredSelectionUsesModelId() {
val customService = configureCustomOpenAIService(stream = true)
service<ModelSettings>().setModel(
FeatureType.AGENT,
"custom-agent-model",
ServiceType.CUSTOM_OPENAI
)
assertThat(shouldStreamCustomOpenAI(FeatureType.AGENT)).isTrue()
expectCustomOpenAI(StreamHttpExchange { request ->
assertThat(request.uri.path).isEqualTo("/v1/chat/completions")
assertThat(request.method).isEqualTo("POST")
assertThat(request.body["stream"]).isEqualTo(true)
assertThat(extractPromptText(request)).contains("Say hello from streamed Custom OpenAI")
chatCompletionChunks("custom-agent-model", "Hello from streamed Custom OpenAI")
})
val result = runAgent(ServiceType.CUSTOM_OPENAI, "Say hello from streamed Custom OpenAI")
assertThat(customService.id).isNotBlank()
assertThat(result.output).isEqualTo("Hello from streamed Custom OpenAI")
assertThat(result.events.text.toString()).isEqualTo("Hello from streamed Custom OpenAI")
}
fun testCustomOpenAIResponsesAgentStreamsWhenStoredSelectionUsesModelId() {
val customService = configureCustomOpenAIService(
path = "/v1/responses",
model = "custom-responses-model",
stream = true,
useResponsesApiBody = true
)
service<ModelSettings>().setModel(
FeatureType.AGENT,
"custom-responses-model",
ServiceType.CUSTOM_OPENAI
)
assertThat(shouldStreamCustomOpenAI(FeatureType.AGENT)).isTrue()
expectCustomOpenAI(StreamHttpExchange { request ->
assertThat(request.uri.path).isEqualTo("/v1/responses")
assertThat(request.method).isEqualTo("POST")
assertThat(request.body["stream"]).isEqualTo(true)
assertThat(extractPromptText(request)).contains("Say hello from Custom OpenAI Responses")
openAiResponsesChunks(
model = "custom-responses-model",
text = "Hello from Custom OpenAI Responses"
)
})
val result = runAgent(ServiceType.CUSTOM_OPENAI, "Say hello from Custom OpenAI Responses")
assertThat(customService.id).isNotBlank()
assertThat(result.output).isEqualTo("Hello from Custom OpenAI Responses")
assertThat(result.events.text.toString()).isEqualTo("Hello from Custom OpenAI Responses")
}
private fun runAgent(
provider: ServiceType,
userMessage: String
@ -246,9 +301,10 @@ class AgentProviderIntegrationTest : IntegrationTest() {
)
}
private fun openAiResponsesChunks(): List<String> {
val model = "gpt-4.1-mini"
val text = "Hello from OpenAI"
private fun openAiResponsesChunks(
model: String = "gpt-4.1-mini",
text: String = "Hello from OpenAI"
): List<String> {
val chunks = text.chunked(4)
return chunks.mapIndexed { index, chunk ->
jsonMapResponse(
@ -573,15 +629,24 @@ class AgentProviderIntegrationTest : IntegrationTest() {
)
}
private fun configureCustomOpenAIService(): CustomServiceSettingsState {
private fun configureCustomOpenAIService(
path: String = "/v1/chat/completions",
model: String = "custom-agent-model",
stream: Boolean = false,
useResponsesApiBody: Boolean = false
): CustomServiceSettingsState {
val settings = service<CustomServicesSettings>()
val serviceState = CustomServiceSettingsState().apply {
name = "Agent Test Custom Service"
chatCompletionSettings.url =
System.getProperty("customOpenAI.baseUrl") + "/v1/chat/completions"
System.getProperty("customOpenAI.baseUrl") + path
chatCompletionSettings.headers.clear()
chatCompletionSettings.body.clear()
chatCompletionSettings.body["model"] = "custom-agent-model"
chatCompletionSettings.body["model"] = model
chatCompletionSettings.body["stream"] = stream
if (useResponsesApiBody) {
chatCompletionSettings.body["input"] = "\$OPENAI_MESSAGES"
}
}
settings.state.services.clear()
settings.state.services.add(serviceState)

View file

@ -0,0 +1,26 @@
package ee.carlrobert.codegpt.agent
import ee.carlrobert.codegpt.agent.tools.DiagnosticsTool
import ee.carlrobert.codegpt.settings.hooks.HookManager
import kotlinx.coroutines.runBlocking
import org.assertj.core.api.Assertions.assertThat
import testsupport.IntegrationTest
import java.io.File
class DiagnosticsToolTest : IntegrationTest() {
fun `test diagnostics tool should be registered in tool specs`() {
assertThat(ToolSpecs.find("Diagnostics")).isNotNull()
}
fun `test diagnostics tool should return missing file error`() {
val missingPath = File(project.basePath, "does-not-exist.txt").absolutePath
val tool = DiagnosticsTool(project, "test-session-id", HookManager(project))
val result = runBlocking {
tool.execute(DiagnosticsTool.Args(missingPath))
}
assertThat(result.error).isEqualTo("File not found: $missingPath")
}
}

View file

@ -0,0 +1,91 @@
package ee.carlrobert.codegpt.agent.clients
import ai.koog.prompt.executor.clients.openai.base.models.Content
import ai.koog.prompt.executor.clients.openai.base.models.OpenAIMessage
import ai.koog.prompt.executor.clients.openai.base.models.OpenAITool
import ai.koog.prompt.executor.clients.openai.base.models.OpenAIToolChoice
import ai.koog.prompt.executor.clients.openai.base.models.OpenAIToolFunction
import ai.koog.prompt.llm.LLModel
import ai.koog.prompt.params.LLMParams
import ee.carlrobert.codegpt.settings.service.custom.CustomServiceChatCompletionSettingsState
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
class CustomOpenAIResponsesApiSerializationTest {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
}
@Test
fun `responses api request should encode tools with top level name`() {
val state = CustomServiceChatCompletionSettingsState().apply {
url = "https://example.com/v1/responses"
body.clear()
body["stream"] = true
body["input"] = "\$OPENAI_MESSAGES"
}
val client = CustomOpenAILLMClient.fromSettingsState("test-key", state)
val serializeMethod = client.javaClass.getDeclaredMethod(
"serializeProviderChatRequest",
List::class.java,
LLModel::class.java,
List::class.java,
OpenAIToolChoice::class.java,
LLMParams::class.java,
Boolean::class.javaPrimitiveType
)
serializeMethod.isAccessible = true
val payload = serializeMethod.invoke(
client,
listOf(OpenAIMessage.User(content = Content.Text("hello"))),
LLModel(
id = "gpt-test",
provider = CustomOpenAILLMClient.CustomOpenAI,
capabilities = emptyList(),
contextLength = 128_000,
maxOutputTokens = 4_096
),
listOf(
OpenAITool(
OpenAIToolFunction(
name = "Diagnostics",
description = "Read IDE diagnostics",
parameters = buildJsonObject {
put("type", "object")
put("properties", buildJsonObject {})
put("required", buildJsonArray {})
},
strict = true
)
)
),
OpenAIToolChoice.Function(OpenAIToolChoice.FunctionName("Diagnostics")),
CustomOpenAIParams(),
true
) as String
val request = json.parseToJsonElement(payload).jsonObject
val tool = request.getValue("tools").jsonArray.single().jsonObject
val toolChoice = request.getValue("tool_choice").jsonObject
assertThat(tool.getValue("type").jsonPrimitive.content).isEqualTo("function")
assertThat(tool.getValue("name").jsonPrimitive.content).isEqualTo("Diagnostics")
assertThat(tool.getValue("description").jsonPrimitive.content).isEqualTo("Read IDE diagnostics")
assertThat(tool.getValue("strict").jsonPrimitive.boolean).isTrue()
assertThat(tool).doesNotContainKey("function")
assertThat(toolChoice.getValue("type").jsonPrimitive.content).isEqualTo("function")
assertThat(toolChoice.getValue("name").jsonPrimitive.content).isEqualTo("Diagnostics")
}
}

View file

@ -0,0 +1,53 @@
package ee.carlrobert.codegpt.agent.clients
import ee.carlrobert.codegpt.settings.service.custom.CustomServiceChatCompletionSettingsState
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
class StreamingPayloadNormalizerTest {
@Test
fun `should unwrap sse data payload`() {
assertThat(normalizeSsePayload("data: {\"id\":\"chunk-1\"}"))
.isEqualTo("{\"id\":\"chunk-1\"}")
}
@Test
fun `should ignore done sentinel`() {
assertThat(normalizeSsePayload("data: [DONE]")).isNull()
}
@Test
fun `should unwrap multiline sse event frames`() {
assertThat(
normalizeSsePayload("event: response.created\ndata: {\"type\":\"response.created\"}\n")
).isEqualTo("{\"type\":\"response.created\"}")
}
@Test
fun `should unwrap compact sse frames where event and data share a line`() {
assertThat(
normalizeSsePayload("event: response.created data: {\"type\":\"response.created\"}")
).isEqualTo("{\"type\":\"response.created\"}")
}
@Test
fun `custom openai client should decode sse wrapped chat completion chunks`() {
val state = CustomServiceChatCompletionSettingsState().apply {
url = "https://example.com/v1/chat/completions"
body["stream"] = true
}
val client = CustomOpenAILLMClient.fromSettingsState("test-key", state)
val decodeMethod = client.javaClass.getDeclaredMethod("decodeStreamingResponse", String::class.java)
decodeMethod.isAccessible = true
val response = decodeMethod.invoke(
client,
"data: {\"choices\":[],\"created\":0,\"id\":\"chunk-1\",\"model\":\"test-model\"}"
) as CustomOpenAIChatCompletionStreamResponse
assertThat(response.id).isEqualTo("chunk-1")
assertThat(response.model).isEqualTo("test-model")
assertThat(response.choices).isEmpty()
}
}

View file

@ -0,0 +1,80 @@
package ee.carlrobert.codegpt.ui.textarea
import com.intellij.openapi.components.service
import ee.carlrobert.codegpt.diagnostics.DiagnosticsFilter
import ee.carlrobert.codegpt.diagnostics.ProjectDiagnosticsService
import ee.carlrobert.codegpt.settings.service.FeatureType
import ee.carlrobert.codegpt.ui.textarea.header.tag.DiagnosticsTagDetails
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.TagManager
import ee.carlrobert.codegpt.ui.textarea.lookup.action.selectedContextFiles
import ee.carlrobert.codegpt.ui.textarea.lookup.group.DiagnosticsGroupItem
import kotlinx.coroutines.runBlocking
import org.assertj.core.api.Assertions.assertThat
import testsupport.IntegrationTest
class DiagnosticsIntegrationTest : IntegrationTest() {
fun `test diagnostics service should return errors for broken file`() {
val brokenFile = myFixture.configureByText(
"Broken.java",
"class Broken { void test() { int value = ; } }"
).virtualFile
myFixture.doHighlighting()
val report = project.service<ProjectDiagnosticsService>()
.collect(brokenFile, DiagnosticsFilter.ERRORS_ONLY)
assertThat(report.hasDiagnostics).isTrue()
assertThat(report.content).contains("[ERROR]")
assertThat(report.content).contains("Broken.java")
}
fun `test diagnostics service should return empty for clean file`() {
val cleanFile = myFixture.configureByText(
"Clean.java",
"class Clean { void test() { int value = 1; } }"
).virtualFile
myFixture.doHighlighting()
val report = project.service<ProjectDiagnosticsService>()
.collect(cleanFile, DiagnosticsFilter.ERRORS_ONLY)
assertThat(report.hasDiagnostics).isFalse()
assertThat(report.content).isBlank()
assertThat(report.error).isNull()
}
fun `test selectedContextFiles should use selected file context only`() {
val firstFile = myFixture.configureByText("First.java", "class First {}").virtualFile
val secondFile = myFixture.configureByText("Second.java", "class Second {}").virtualFile
val unselectedEditorTag = EditorTagDetails(secondFile).apply { selected = false }
val contextFiles = selectedContextFiles(
listOf(
FileTagDetails(firstFile),
DiagnosticsTagDetails(firstFile, DiagnosticsFilter.ALL),
EditorTagDetails(secondFile),
unselectedEditorTag
)
)
assertThat(contextFiles.map { it.path })
.containsExactly(firstFile.path, secondFile.path)
}
fun `test diagnostics group should be available in agent mode with file context`() {
val file = myFixture.configureByText("AgentFile.java", "class AgentFile {}").virtualFile
val tagManager = TagManager().apply {
addTag(FileTagDetails(file))
}
val groups = SearchManager(project, tagManager, FeatureType.AGENT).getDefaultGroups()
val diagnosticsGroup = groups.filterIsInstance<DiagnosticsGroupItem>().single()
val actions = runBlocking { diagnosticsGroup.getLookupItems("") }
assertThat(diagnosticsGroup.enabled).isTrue()
assertThat(actions.map { it.displayName }).containsExactly("Errors only", "All")
}
}