diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt index 33a7182d62..ca1e5cbe90 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt @@ -18,6 +18,9 @@ import cc.unitmesh.devins.idea.toolwindow.codereview.IdeaCodeReviewViewModel import cc.unitmesh.devins.idea.toolwindow.header.IdeaAgentTabsHeader import cc.unitmesh.devins.idea.toolwindow.knowledge.IdeaKnowledgeContent import cc.unitmesh.devins.idea.toolwindow.knowledge.IdeaKnowledgeViewModel +import cc.unitmesh.devins.idea.toolwindow.remote.IdeaRemoteAgentContent +import cc.unitmesh.devins.idea.toolwindow.remote.IdeaRemoteAgentViewModel +import cc.unitmesh.devins.idea.toolwindow.remote.getEffectiveProjectId import cc.unitmesh.devins.idea.toolwindow.status.IdeaToolLoadingStatusBar import cc.unitmesh.devins.idea.toolwindow.timeline.IdeaEmptyStateMessage import cc.unitmesh.devins.idea.toolwindow.timeline.IdeaTimelineContent @@ -77,6 +80,13 @@ fun IdeaAgentApp( // Knowledge ViewModel (created lazily when needed) var knowledgeViewModel by remember { mutableStateOf(null) } + // Remote Agent ViewModel (created lazily when needed) + var remoteAgentViewModel by remember { mutableStateOf(null) } + + // Remote agent state for input handling + var remoteProjectId by remember { mutableStateOf("") } + var remoteGitUrl by remember { mutableStateOf("") } + // Auto-scroll to bottom when new items arrive LaunchedEffect(timeline.size, streamingOutput) { if (timeline.isNotEmpty() || streamingOutput.isNotEmpty()) { @@ -87,7 +97,7 @@ fun IdeaAgentApp( } } - // Create CodeReviewViewModel when switching to CODE_REVIEW tab + // Create ViewModels when switching tabs LaunchedEffect(currentAgentType) { if (currentAgentType == AgentType.CODE_REVIEW && codeReviewViewModel == null) { codeReviewViewModel = IdeaCodeReviewViewModel(project, coroutineScope) @@ -95,6 +105,13 @@ fun IdeaAgentApp( if (currentAgentType == AgentType.KNOWLEDGE && knowledgeViewModel == null) { knowledgeViewModel = IdeaKnowledgeViewModel(project, coroutineScope) } + if (currentAgentType == AgentType.REMOTE && remoteAgentViewModel == null) { + remoteAgentViewModel = IdeaRemoteAgentViewModel( + project = project, + coroutineScope = coroutineScope, + serverUrl = "http://localhost:8080" + ) + } } // Dispose ViewModels when leaving their tabs @@ -108,6 +125,10 @@ fun IdeaAgentApp( knowledgeViewModel?.dispose() knowledgeViewModel = null } + if (currentAgentType != AgentType.REMOTE) { + remoteAgentViewModel?.dispose() + remoteAgentViewModel = null + } } } @@ -133,13 +154,23 @@ fun IdeaAgentApp( .weight(1f) ) { when (currentAgentType) { - AgentType.CODING, AgentType.REMOTE, AgentType.LOCAL_CHAT -> { + AgentType.CODING, AgentType.LOCAL_CHAT -> { IdeaTimelineContent( timeline = timeline, streamingOutput = streamingOutput, listState = listState ) } + AgentType.REMOTE -> { + remoteAgentViewModel?.let { vm -> + IdeaRemoteAgentContent( + viewModel = vm, + listState = listState, + onProjectIdChange = { remoteProjectId = it }, + onGitUrlChange = { remoteGitUrl = it } + ) + } ?: IdeaEmptyStateMessage("Loading Remote Agent...") + } AgentType.CODE_REVIEW -> { codeReviewViewModel?.let { vm -> IdeaCodeReviewContent( @@ -159,7 +190,7 @@ fun IdeaAgentApp( Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) // Input area (only for chat-based modes) - if (currentAgentType == AgentType.CODING || currentAgentType == AgentType.REMOTE || currentAgentType == AgentType.LOCAL_CHAT) { + if (currentAgentType == AgentType.CODING || currentAgentType == AgentType.LOCAL_CHAT) { IdeaDevInInputArea( project = project, parentDisposable = viewModel, @@ -181,6 +212,39 @@ fun IdeaAgentApp( ) } + // Remote agent input area + if (currentAgentType == AgentType.REMOTE) { + remoteAgentViewModel?.let { remoteVm -> + val remoteIsExecuting by remoteVm.isExecuting.collectAsState() + val remoteIsConnected by remoteVm.isConnected.collectAsState() + + IdeaDevInInputArea( + project = project, + parentDisposable = viewModel, + isProcessing = remoteIsExecuting, + onSend = { task -> + val effectiveProjectId = getEffectiveProjectId(remoteProjectId, remoteGitUrl) + if (effectiveProjectId.isNotBlank()) { + remoteVm.executeTask(effectiveProjectId, task, remoteGitUrl) + } else { + remoteVm.renderer.renderError("Please provide a project or Git URL") + } + }, + onAbort = { remoteVm.cancelTask() }, + workspacePath = project.basePath, + totalTokens = null, + onSettingsClick = { viewModel.setShowConfigDialog(true) }, + onAtClick = {}, + availableConfigs = availableConfigs, + currentConfigName = currentConfigName, + onConfigSelect = { config -> + viewModel.setActiveConfig(config.name) + }, + onConfigureClick = { viewModel.setShowConfigDialog(true) } + ) + } + } + // Tool loading status bar IdeaToolLoadingStatusBar( viewModel = viewModel, diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentClient.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentClient.kt new file mode 100644 index 0000000000..8e2149b0c4 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentClient.kt @@ -0,0 +1,162 @@ +package cc.unitmesh.devins.idea.toolwindow.remote + +import cc.unitmesh.agent.RemoteAgentEvent +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.sse.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlin.time.Duration.Companion.seconds + +/** + * Remote Agent Client for IntelliJ IDEA plugin. + * + * Connects to mpp-server and streams agent execution events via SSE. + * This is adapted from mpp-ui's RemoteAgentClient for use in the IDE plugin. + */ +class IdeaRemoteAgentClient( + private val baseUrl: String = "http://localhost:8080" +) { + private val httpClient: HttpClient = HttpClient(CIO) { + install(SSE) { + reconnectionTime = 30.seconds + maxReconnectionAttempts = 3 + } + + // We handle HTTP errors manually to provide better error messages + // SSE connections need explicit status checking + expectSuccess = false + + engine { + maxConnectionsCount = 1000 + endpoint { + maxConnectionsPerRoute = 100 + pipelineMaxSize = 20 + keepAliveTime = 5000 + connectTimeout = 5000 + connectAttempts = 5 + } + } + } + + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + /** + * Health check to verify server is running + */ + suspend fun healthCheck(): HealthResponse { + val response = httpClient.get("$baseUrl/health") + if (!response.status.isSuccess()) { + throw RemoteAgentException("Health check failed: ${response.status}") + } + return json.decodeFromString(response.bodyAsText()) + } + + /** + * Get list of available projects from server + */ + suspend fun getProjects(): ProjectListResponse { + val response = httpClient.get("$baseUrl/api/projects") + if (!response.status.isSuccess()) { + throw RemoteAgentException("Failed to fetch projects: ${response.status}") + } + return json.decodeFromString(response.bodyAsText()) + } + + /** + * Execute agent task with SSE streaming + * Returns a Flow of RemoteAgentEvent for reactive processing + */ + fun executeStream(request: RemoteAgentRequest): Flow = flow { + try { + httpClient.sse( + urlString = "$baseUrl/api/agent/stream", + request = { + method = HttpMethod.Post + contentType(ContentType.Application.Json) + setBody(json.encodeToString(RemoteAgentRequest.serializer(), request)) + } + ) { + // Check HTTP status before processing SSE events + if (!call.response.status.isSuccess()) { + throw RemoteAgentException("Stream connection failed: ${call.response.status}") + } + + incoming + .mapNotNull { event -> + event.data?.takeIf { data -> + !data.trim().equals("[DONE]", ignoreCase = true) + }?.let { data -> + val eventType = event.event ?: "message" + RemoteAgentEvent.from(eventType, data) + } + } + .collect { parsedEvent -> + emit(parsedEvent) + } + } + } catch (e: Exception) { + e.printStackTrace() + throw RemoteAgentException("Stream connection failed: ${e.message}", e) + } + } + + fun close() { + httpClient.close() + } +} + +/** + * Request/Response Data Classes + */ +@Serializable +data class RemoteAgentRequest( + val projectId: String, + val task: String, + val llmConfig: LLMConfig? = null, + val gitUrl: String? = null, + val branch: String? = null, + val username: String? = null, + val password: String? = null +) + +@Serializable +data class LLMConfig( + val provider: String, + val modelName: String, + val apiKey: String, + val baseUrl: String? = null +) + +@Serializable +data class HealthResponse( + val status: String +) + +@Serializable +data class ProjectInfo( + val id: String, + val name: String, + val path: String, + val description: String +) + +@Serializable +data class ProjectListResponse( + val projects: List +) + +/** + * Exception for remote agent errors + */ +class RemoteAgentException(message: String, cause: Throwable? = null) : Exception(message, cause) + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentContent.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentContent.kt new file mode 100644 index 0000000000..deb8d42caf --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentContent.kt @@ -0,0 +1,324 @@ +package cc.unitmesh.devins.idea.toolwindow.remote + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import cc.unitmesh.devins.idea.toolwindow.timeline.IdeaTimelineContent +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import kotlinx.coroutines.flow.distinctUntilChanged +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.* + +/** + * Remote Agent Content UI for IntelliJ IDEA plugin. + * + * Displays: + * - Server configuration inputs (URL, project/git URL) + * - Connection status indicator + * - Timeline content from remote agent execution + */ +@Composable +fun IdeaRemoteAgentContent( + viewModel: IdeaRemoteAgentViewModel, + listState: LazyListState, + onProjectIdChange: (String) -> Unit = {}, + onGitUrlChange: (String) -> Unit = {}, + modifier: Modifier = Modifier +) { + val timeline by viewModel.renderer.timeline.collectAsState() + val streamingOutput by viewModel.renderer.currentStreamingOutput.collectAsState() + val isConnected by viewModel.isConnected.collectAsState() + val connectionError by viewModel.connectionError.collectAsState() + val availableProjects by viewModel.availableProjects.collectAsState() + + var serverUrl by remember { mutableStateOf(viewModel.serverUrl) } + var projectId by remember { mutableStateOf("") } + var gitUrl by remember { mutableStateOf("") } + + // Check connection on initial load and when server URL changes + LaunchedEffect(serverUrl) { + if (serverUrl.isNotBlank()) { + viewModel.updateServerUrl(serverUrl) + viewModel.checkConnection() + } + } + + // Propagate changes to parent + LaunchedEffect(projectId) { + onProjectIdChange(projectId) + } + LaunchedEffect(gitUrl) { + onGitUrlChange(gitUrl) + } + + Column( + modifier = modifier.fillMaxSize() + ) { + // Server Configuration Panel + RemoteConfigPanel( + serverUrl = serverUrl, + onServerUrlChange = { serverUrl = it }, + projectId = projectId, + onProjectIdChange = { projectId = it }, + gitUrl = gitUrl, + onGitUrlChange = { gitUrl = it }, + isConnected = isConnected, + connectionError = connectionError, + availableProjects = availableProjects, + onConnect = { viewModel.checkConnection() }, + modifier = Modifier.fillMaxWidth() + ) + + // Timeline Content + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + IdeaTimelineContent( + timeline = timeline, + streamingOutput = streamingOutput, + listState = listState + ) + } + } +} + +/** + * Configuration panel for remote server settings. + * Uses TextFieldState for Jewel TextField compatibility. + */ +@Composable +private fun RemoteConfigPanel( + serverUrl: String, + onServerUrlChange: (String) -> Unit, + projectId: String, + onProjectIdChange: (String) -> Unit, + gitUrl: String, + onGitUrlChange: (String) -> Unit, + isConnected: Boolean, + connectionError: String?, + availableProjects: List, + onConnect: () -> Unit, + modifier: Modifier = Modifier +) { + // TextFieldState for Jewel TextField + val serverUrlState = rememberTextFieldState(serverUrl) + val projectIdState = rememberTextFieldState(projectId) + val gitUrlState = rememberTextFieldState(gitUrl) + + // Sync server URL state to callback + LaunchedEffect(Unit) { + snapshotFlow { serverUrlState.text.toString() } + .distinctUntilChanged() + .collect { onServerUrlChange(it) } + } + + // Sync project ID state to callback + LaunchedEffect(Unit) { + snapshotFlow { projectIdState.text.toString() } + .distinctUntilChanged() + .collect { onProjectIdChange(it) } + } + + // Sync git URL state to callback + LaunchedEffect(Unit) { + snapshotFlow { gitUrlState.text.toString() } + .distinctUntilChanged() + .collect { onGitUrlChange(it) } + } + + // Sync external changes to text field states + LaunchedEffect(serverUrl) { + if (serverUrlState.text.toString() != serverUrl) { + serverUrlState.setTextAndPlaceCursorAtEnd(serverUrl) + } + } + LaunchedEffect(projectId) { + if (projectIdState.text.toString() != projectId) { + projectIdState.setTextAndPlaceCursorAtEnd(projectId) + } + } + LaunchedEffect(gitUrl) { + if (gitUrlState.text.toString() != gitUrl) { + gitUrlState.setTextAndPlaceCursorAtEnd(gitUrl) + } + } + + Column( + modifier = modifier + .background(JewelTheme.globalColors.panelBackground) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Server URL row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Server:", + style = JewelTheme.defaultTextStyle, + modifier = Modifier.width(60.dp) + ) + + TextField( + state = serverUrlState, + placeholder = { Text("http://localhost:8080") }, + modifier = Modifier.weight(1f) + ) + + DefaultButton( + onClick = onConnect, + modifier = Modifier.height(32.dp) + ) { + Text("Connect") + } + } + + // Connection Status + ConnectionStatusBar( + isConnected = isConnected, + serverUrl = serverUrl, + connectionError = connectionError, + modifier = Modifier.fillMaxWidth() + ) + + // Project/Git URL inputs (only show when connected) + if (isConnected) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Project:", + style = JewelTheme.defaultTextStyle, + modifier = Modifier.width(60.dp) + ) + + if (availableProjects.isNotEmpty()) { + Dropdown( + menuContent = { + availableProjects.forEach { project -> + selectableItem( + selected = projectId == project.id, + onClick = { + onProjectIdChange(project.id) + projectIdState.setTextAndPlaceCursorAtEnd(project.id) + } + ) { + Text(project.name) + } + } + }, + modifier = Modifier.weight(1f) + ) { + Text( + text = availableProjects.find { it.id == projectId }?.name ?: "Select project..." + ) + } + } else { + TextField( + state = projectIdState, + placeholder = { Text("Project ID or name") }, + modifier = Modifier.weight(1f) + ) + } + } + + // Git URL input (optional) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Git URL:", + style = JewelTheme.defaultTextStyle, + modifier = Modifier.width(60.dp) + ) + + TextField( + state = gitUrlState, + placeholder = { Text("https://github.com/user/repo.git (optional)") }, + modifier = Modifier.weight(1f) + ) + } + } + } +} + +/** + * Connection status indicator + */ +@Composable +private fun ConnectionStatusBar( + isConnected: Boolean, + serverUrl: String, + connectionError: String?, + modifier: Modifier = Modifier +) { + val statusColor by animateColorAsState( + targetValue = if (isConnected) AutoDevColors.Green.c400 else AutoDevColors.Red.c400, + label = "statusColor" + ) + + Row( + modifier = modifier + .clip(RoundedCornerShape(6.dp)) + .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f)) + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Status indicator dot + Box( + modifier = Modifier + .size(8.dp) + .background(color = statusColor, shape = CircleShape) + ) + + Text( + text = if (isConnected) { + "Connected to $serverUrl" + } else if (connectionError != null) { + "Error: $connectionError" + } else { + "Not connected" + }, + style = JewelTheme.defaultTextStyle.copy( + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + ) + ) + } +} + +/** + * Get the project ID or Git URL for task execution. + * Handles trailing slashes and empty segments in Git URLs. + */ +fun getEffectiveProjectId(projectId: String, gitUrl: String): String { + return if (gitUrl.isNotBlank()) { + gitUrl.trimEnd('/') + .split('/') + .lastOrNull { it.isNotBlank() } + ?.removeSuffix(".git") + ?.ifBlank { projectId } + ?: projectId + } else { + projectId + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModel.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModel.kt new file mode 100644 index 0000000000..8f8a2d2e3c --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModel.kt @@ -0,0 +1,289 @@ +package cc.unitmesh.devins.idea.toolwindow.remote + +import cc.unitmesh.agent.RemoteAgentEvent +import cc.unitmesh.devins.idea.renderer.JewelRenderer +import cc.unitmesh.devins.ui.config.ConfigManager +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * ViewModel for Remote Agent in IntelliJ IDEA plugin. + * + * Connects to mpp-server and streams agent execution events, + * forwarding them to JewelRenderer for UI rendering. + * + * This is adapted from mpp-ui's RemoteCodingAgentViewModel. + */ +class IdeaRemoteAgentViewModel( + private val project: Project, + private val coroutineScope: CoroutineScope, + serverUrl: String = "http://localhost:8080", + private val useServerConfig: Boolean = false +) : Disposable { + + private var _serverUrl = serverUrl + val serverUrl: String get() = _serverUrl + + private var client = IdeaRemoteAgentClient(_serverUrl) + + val renderer = JewelRenderer() + + private val _isExecuting = MutableStateFlow(false) + val isExecuting: StateFlow = _isExecuting.asStateFlow() + + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() + + private val _connectionError = MutableStateFlow(null) + val connectionError: StateFlow = _connectionError.asStateFlow() + + private val _availableProjects = MutableStateFlow>(emptyList()) + val availableProjects: StateFlow> = _availableProjects.asStateFlow() + + private var currentExecutionJob: Job? = null + + /** + * Update server URL and recreate client + */ + fun updateServerUrl(newUrl: String) { + if (newUrl != _serverUrl) { + _serverUrl = newUrl + client.close() + client = IdeaRemoteAgentClient(_serverUrl) + _isConnected.value = false + _connectionError.value = null + _availableProjects.value = emptyList() + } + } + + /** + * Check connection to server + */ + fun checkConnection() { + coroutineScope.launch { + try { + val health = client.healthCheck() + _isConnected.value = health.status == "ok" + _connectionError.value = null + + if (_isConnected.value) { + val projectList = client.getProjects() + _availableProjects.value = projectList.projects + } + } catch (e: Exception) { + _isConnected.value = false + _connectionError.value = e.message ?: "Failed to connect to server" + } + } + } + + /** + * Execute a task on the remote server + */ + fun executeTask(projectId: String, task: String, gitUrl: String = "") { + if (_isExecuting.value) { + println("Agent is already executing") + return + } + + if (!_isConnected.value) { + renderer.renderError("Not connected to server. Please check server URL.") + return + } + + _isExecuting.value = true + renderer.clearError() + renderer.addUserMessage(task) + + currentExecutionJob = coroutineScope.launch { + try { + val llmConfig = if (!useServerConfig) { + val config = ConfigManager.load() + val activeConfig = config.getActiveModelConfig() + + if (activeConfig == null) { + renderer.renderError("No active LLM configuration found. Please configure your model first.") + _isExecuting.value = false + return@launch + } + + LLMConfig( + provider = activeConfig.provider.name, + modelName = activeConfig.modelName, + apiKey = activeConfig.apiKey ?: "", + baseUrl = activeConfig.baseUrl + ) + } else { + null + } + + val request = buildRequest(projectId, task, gitUrl, llmConfig) + + client.executeStream(request).collect { event -> + handleRemoteEvent(event) + + if (event is RemoteAgentEvent.Complete) { + _isExecuting.value = false + currentExecutionJob = null + } + } + + } catch (e: CancellationException) { + renderer.forceStop() + renderer.renderError("Task cancelled by user") + _isExecuting.value = false + currentExecutionJob = null + } catch (e: Exception) { + renderer.renderError(e.message ?: "Unknown error") + _isExecuting.value = false + currentExecutionJob = null + } + } + } + + private fun buildRequest( + projectId: String, + task: String, + gitUrl: String, + llmConfig: LLMConfig? + ): RemoteAgentRequest { + return if (gitUrl.isNotBlank()) { + RemoteAgentRequest( + projectId = extractProjectIdFromUrl(gitUrl) ?: "temp-project", + task = task, + llmConfig = llmConfig, + gitUrl = gitUrl + ) + } else { + val isGitUrl = projectId.startsWith("http://") || + projectId.startsWith("https://") || + projectId.startsWith("git@") + + if (isGitUrl) { + RemoteAgentRequest( + projectId = extractProjectIdFromUrl(projectId) ?: "temp-project", + task = task, + llmConfig = llmConfig, + gitUrl = projectId + ) + } else { + RemoteAgentRequest( + projectId = projectId, + task = task, + llmConfig = llmConfig + ) + } + } + } + + /** + * Extract project ID from a Git URL, handling trailing slashes and empty segments. + */ + private fun extractProjectIdFromUrl(url: String): String? { + return url.trimEnd('/') + .split('/') + .lastOrNull { it.isNotBlank() } + ?.removeSuffix(".git") + ?.ifBlank { null } + } + + /** + * Handle remote events and forward to JewelRenderer + */ + private fun handleRemoteEvent(event: RemoteAgentEvent) { + when (event) { + is RemoteAgentEvent.CloneProgress -> { + if (event.progress != null) { + renderer.renderLLMResponseStart() + renderer.renderLLMResponseChunk("📦 Cloning repository: ${event.stage} (${event.progress}%)") + renderer.renderLLMResponseEnd() + } + } + + is RemoteAgentEvent.CloneLog -> { + if (!event.isError && (event.message.contains("✓") || event.message.contains("ready"))) { + renderer.renderLLMResponseStart() + renderer.renderLLMResponseChunk(event.message) + renderer.renderLLMResponseEnd() + } else if (event.isError) { + renderer.renderError(event.message) + } + } + + is RemoteAgentEvent.Iteration -> { + renderer.renderIterationHeader(event.current, event.max) + } + + is RemoteAgentEvent.LLMChunk -> { + if (!renderer.isProcessing.value) { + renderer.renderLLMResponseStart() + } + renderer.renderLLMResponseChunk(event.chunk) + } + + is RemoteAgentEvent.ToolCall -> { + if (renderer.isProcessing.value) { + renderer.renderLLMResponseEnd() + } + renderer.renderToolCall(event.toolName, event.params) + } + + is RemoteAgentEvent.ToolResult -> { + renderer.renderToolResult( + toolName = event.toolName, + success = event.success, + output = event.output, + fullOutput = event.output, + metadata = emptyMap() + ) + } + + is RemoteAgentEvent.Error -> { + renderer.renderError(event.message) + } + + is RemoteAgentEvent.Complete -> { + if (renderer.isProcessing.value) { + renderer.renderLLMResponseEnd() + } + renderer.renderFinalResult(event.success, event.message, event.iterations) + } + } + } + + /** + * Cancel current task + */ + fun cancelTask() { + if (_isExecuting.value && currentExecutionJob != null) { + currentExecutionJob?.cancel("Task cancelled by user") + currentExecutionJob = null + _isExecuting.value = false + } + } + + /** + * Clear chat history + */ + fun clearHistory() { + renderer.clearTimeline() + } + + /** + * Clear error state + */ + fun clearError() { + renderer.clearError() + _connectionError.value = null + } + + override fun dispose() { + currentExecutionJob?.cancel() + client.close() + } +} + diff --git a/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModelTest.kt b/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModelTest.kt new file mode 100644 index 0000000000..0340e0598e --- /dev/null +++ b/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModelTest.kt @@ -0,0 +1,477 @@ +package cc.unitmesh.devins.idea.toolwindow.remote + +import cc.unitmesh.devins.idea.renderer.JewelRenderer +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.first +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Unit tests for IdeaRemoteAgentViewModel. + * + * Tests the ViewModel's functionality including: + * - Initial state + * - Server URL management + * - Connection state handling + * - Task cancellation + * - History management + * - Event handling (via renderer) + * + * Note: These tests do not require a real server connection. + * Network-related tests are skipped as they would require mocking. + */ +class IdeaRemoteAgentViewModelTest { + + private lateinit var testScope: CoroutineScope + + @BeforeEach + fun setUp() { + testScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + } + + @AfterEach + fun tearDown() { + testScope.cancel() + } + + @Test + fun testInitialState() = runBlocking { + // Create a mock project-free test by testing the renderer and state directly + // We can't easily test the full ViewModel without IntelliJ Platform, + // but we can test the renderer and state management + val renderer = JewelRenderer() + + // Verify initial renderer state + val timeline = renderer.timeline.first() + assertTrue(timeline.isEmpty()) + + val isProcessing = renderer.isProcessing.first() + assertFalse(isProcessing) + + val errorMessage = renderer.errorMessage.first() + assertNull(errorMessage) + } + + @Test + fun testRendererHandlesIterationEvent() = runBlocking { + val renderer = JewelRenderer() + + // Simulate handling iteration event + renderer.renderIterationHeader(3, 10) + + val currentIteration = renderer.currentIteration.first() + assertEquals(3, currentIteration) + + val maxIterations = renderer.maxIterations.first() + assertEquals(10, maxIterations) + } + + @Test + fun testRendererHandlesLLMChunkEvent() = runBlocking { + val renderer = JewelRenderer() + + // Simulate LLM streaming + renderer.renderLLMResponseStart() + assertTrue(renderer.isProcessing.first()) + + renderer.renderLLMResponseChunk("Hello ") + renderer.renderLLMResponseChunk("world!") + + val streamingOutput = renderer.currentStreamingOutput.first() + assertTrue(streamingOutput.contains("Hello")) + assertTrue(streamingOutput.contains("world")) + + renderer.renderLLMResponseEnd() + assertFalse(renderer.isProcessing.first()) + } + + @Test + fun testRendererHandlesToolCallEvent() = runBlocking { + val renderer = JewelRenderer() + + // Simulate tool call + renderer.renderToolCall("read-file", "path=\"/test/file.txt\"") + + val currentToolCall = renderer.currentToolCall.first() + assertNotNull(currentToolCall) + + val timeline = renderer.timeline.first() + assertEquals(1, timeline.size) + assertTrue(timeline.first() is JewelRenderer.TimelineItem.ToolCallItem) + } + + @Test + fun testRendererHandlesToolResultEvent() = runBlocking { + val renderer = JewelRenderer() + + // Simulate tool call and result + renderer.renderToolCall("read-file", "path=\"/test/file.txt\"") + renderer.renderToolResult( + toolName = "read-file", + success = true, + output = "File content", + fullOutput = "Full file content", + metadata = emptyMap() + ) + + val currentToolCall = renderer.currentToolCall.first() + assertNull(currentToolCall) + + val timeline = renderer.timeline.first() + assertEquals(1, timeline.size) + + val toolItem = timeline.first() as JewelRenderer.TimelineItem.ToolCallItem + assertEquals(true, toolItem.success) + } + + @Test + fun testRendererHandlesErrorEvent() = runBlocking { + val renderer = JewelRenderer() + + renderer.renderError("Connection failed") + + val errorMessage = renderer.errorMessage.first() + assertEquals("Connection failed", errorMessage) + + val timeline = renderer.timeline.first() + assertEquals(1, timeline.size) + assertTrue(timeline.first() is JewelRenderer.TimelineItem.ErrorItem) + } + + @Test + fun testRendererHandlesCompleteEvent() = runBlocking { + val renderer = JewelRenderer() + + renderer.renderFinalResult(true, "Task completed", 5) + + val timeline = renderer.timeline.first() + assertEquals(1, timeline.size) + + val item = timeline.first() as JewelRenderer.TimelineItem.TaskCompleteItem + assertTrue(item.success) + assertEquals("Task completed", item.message) + assertEquals(5, item.iterations) + } + + @Test + fun testRendererClearTimeline() = runBlocking { + val renderer = JewelRenderer() + + // Add some items + renderer.addUserMessage("User message") + renderer.renderError("An error") + + var timeline = renderer.timeline.first() + assertEquals(2, timeline.size) + + // Clear timeline + renderer.clearTimeline() + + timeline = renderer.timeline.first() + assertTrue(timeline.isEmpty()) + + val errorMessage = renderer.errorMessage.first() + assertNull(errorMessage) + } + + @Test + fun testRendererForceStop() = runBlocking { + val renderer = JewelRenderer() + + // Start streaming + renderer.renderLLMResponseStart() + renderer.renderLLMResponseChunk("Partial output") + + assertTrue(renderer.isProcessing.first()) + + // Force stop + renderer.forceStop() + + assertFalse(renderer.isProcessing.first()) + + // Verify interrupted message was added + val timeline = renderer.timeline.first() + assertTrue(timeline.isNotEmpty()) + val lastItem = timeline.last() + assertTrue(lastItem is JewelRenderer.TimelineItem.MessageItem) + assertTrue((lastItem as JewelRenderer.TimelineItem.MessageItem).content.contains("[Interrupted]")) + } + + @Test + fun testRendererClearError() = runBlocking { + val renderer = JewelRenderer() + + // Set error + renderer.renderError("Test error") + assertEquals("Test error", renderer.errorMessage.first()) + + // Clear error + renderer.clearError() + assertNull(renderer.errorMessage.first()) + } + + @Test + fun testRendererAddUserMessage() = runBlocking { + val renderer = JewelRenderer() + + renderer.addUserMessage("Hello from user") + + val timeline = renderer.timeline.first() + assertEquals(1, timeline.size) + + val item = timeline.first() as JewelRenderer.TimelineItem.MessageItem + assertEquals(JewelRenderer.MessageRole.USER, item.role) + assertEquals("Hello from user", item.content) + } + + @Test + fun testRemoteAgentRequestBuilder() { + // Test the request building logic + val projectId = "test-project" + val task = "Fix the bug" + val gitUrl = "" + + // When gitUrl is empty, should use projectId + val request = RemoteAgentRequest( + projectId = projectId, + task = task, + llmConfig = null, + gitUrl = if (gitUrl.isNotBlank()) gitUrl else null + ) + + assertEquals("test-project", request.projectId) + assertEquals("Fix the bug", request.task) + assertNull(request.gitUrl) + } + + @Test + fun testRemoteAgentRequestWithGitUrl() { + // Test the request building logic with git URL + val gitUrl = "https://github.com/user/repo.git" + val task = "Fix the bug" + + val projectId = gitUrl.split('/').lastOrNull()?.removeSuffix(".git") ?: "temp-project" + + val request = RemoteAgentRequest( + projectId = projectId, + task = task, + llmConfig = null, + gitUrl = gitUrl + ) + + assertEquals("repo", request.projectId) + assertEquals("Fix the bug", request.task) + assertEquals(gitUrl, request.gitUrl) + } + + @Test + fun testLLMConfigSerialization() { + val config = LLMConfig( + provider = "OpenAI", + modelName = "gpt-4", + apiKey = "test-key", + baseUrl = "https://api.openai.com" + ) + + assertEquals("OpenAI", config.provider) + assertEquals("gpt-4", config.modelName) + assertEquals("test-key", config.apiKey) + assertEquals("https://api.openai.com", config.baseUrl) + } + + @Test + fun testHealthResponseParsing() { + val response = HealthResponse(status = "ok") + assertEquals("ok", response.status) + } + + @Test + fun testProjectInfoParsing() { + val project = ProjectInfo( + id = "proj-1", + name = "My Project", + path = "/path/to/project", + description = "A test project" + ) + + assertEquals("proj-1", project.id) + assertEquals("My Project", project.name) + assertEquals("/path/to/project", project.path) + assertEquals("A test project", project.description) + } + + // Tests for getEffectiveProjectId utility function + + @Test + fun testGetEffectiveProjectIdWithNormalGitUrl() { + val result = getEffectiveProjectId("fallback", "https://github.com/user/repo.git") + assertEquals("repo", result) + } + + @Test + fun testGetEffectiveProjectIdWithTrailingSlash() { + // Edge case: URL with trailing slash should still extract correct project ID + val result = getEffectiveProjectId("fallback", "https://github.com/user/repo/") + assertEquals("repo", result) + } + + @Test + fun testGetEffectiveProjectIdWithMultipleTrailingSlashes() { + val result = getEffectiveProjectId("fallback", "https://github.com/user/repo///") + assertEquals("repo", result) + } + + @Test + fun testGetEffectiveProjectIdWithEmptyGitUrl() { + val result = getEffectiveProjectId("my-project", "") + assertEquals("my-project", result) + } + + @Test + fun testGetEffectiveProjectIdWithBlankGitUrl() { + val result = getEffectiveProjectId("my-project", " ") + assertEquals("my-project", result) + } + + @Test + fun testGetEffectiveProjectIdWithOnlySlashes() { + // Edge case: URL that is just slashes should fallback to projectId + val result = getEffectiveProjectId("fallback", "///") + assertEquals("fallback", result) + } + + @Test + fun testGetEffectiveProjectIdWithGitSuffix() { + val result = getEffectiveProjectId("fallback", "https://github.com/user/my-repo.git") + assertEquals("my-repo", result) + } + + @Test + fun testGetEffectiveProjectIdWithoutGitSuffix() { + val result = getEffectiveProjectId("fallback", "https://github.com/user/my-repo") + assertEquals("my-repo", result) + } + + // Tests for IdeaRemoteAgentClient data classes and state management + + @Test + fun testRemoteAgentClientDataClassDefaultValues() { + // Test RemoteAgentRequest with minimal required fields + val request = RemoteAgentRequest( + projectId = "test-project", + task = "Test task" + ) + + assertEquals("test-project", request.projectId) + assertEquals("Test task", request.task) + assertNull(request.llmConfig) + assertNull(request.gitUrl) + assertNull(request.branch) + assertNull(request.username) + assertNull(request.password) + } + + @Test + fun testRemoteAgentRequestWithAllFields() { + val llmConfig = LLMConfig( + provider = "OpenAI", + modelName = "gpt-4", + apiKey = "test-key", + baseUrl = "https://api.openai.com" + ) + + val request = RemoteAgentRequest( + projectId = "test-project", + task = "Test task", + llmConfig = llmConfig, + gitUrl = "https://github.com/user/repo.git", + branch = "main", + username = "user", + password = "pass" + ) + + assertEquals("test-project", request.projectId) + assertEquals("Test task", request.task) + assertNotNull(request.llmConfig) + assertEquals("https://github.com/user/repo.git", request.gitUrl) + assertEquals("main", request.branch) + assertEquals("user", request.username) + assertEquals("pass", request.password) + } + + @Test + fun testLLMConfigWithNullBaseUrl() { + val config = LLMConfig( + provider = "Claude", + modelName = "claude-3", + apiKey = "test-key", + baseUrl = null + ) + + assertEquals("Claude", config.provider) + assertEquals("claude-3", config.modelName) + assertEquals("test-key", config.apiKey) + assertNull(config.baseUrl) + } + + @Test + fun testProjectListResponseEmpty() { + val response = ProjectListResponse(projects = emptyList()) + assertTrue(response.projects.isEmpty()) + } + + @Test + fun testProjectListResponseWithMultipleProjects() { + val projects = listOf( + ProjectInfo(id = "proj-1", name = "Project 1", path = "/path/1", description = "First"), + ProjectInfo(id = "proj-2", name = "Project 2", path = "/path/2", description = "Second") + ) + val response = ProjectListResponse(projects = projects) + + assertEquals(2, response.projects.size) + assertEquals("proj-1", response.projects[0].id) + assertEquals("proj-2", response.projects[1].id) + } + + @Test + fun testRemoteAgentExceptionWithCause() { + val cause = RuntimeException("Original error") + val exception = RemoteAgentException("Wrapper error", cause) + + assertEquals("Wrapper error", exception.message) + assertEquals(cause, exception.cause) + } + + @Test + fun testRemoteAgentExceptionWithoutCause() { + val exception = RemoteAgentException("Simple error") + + assertEquals("Simple error", exception.message) + assertNull(exception.cause) + } + + // Test utility function for extracting project ID from various URL formats + + @Test + fun testGetEffectiveProjectIdWithSshUrl() { + // SSH URLs like git@github.com:user/repo.git + val result = getEffectiveProjectId("fallback", "git@github.com:user/repo.git") + // The function splits by '/', so for SSH URLs it would get "user/repo.git" and then take last + // Actually it splits by '/' so git@github.com:user would be first, repo.git second + assertEquals("repo", result) + } + + @Test + fun testGetEffectiveProjectIdWithDeepPath() { + // URLs with deeper paths + val result = getEffectiveProjectId("fallback", "https://gitlab.com/group/subgroup/repo.git") + assertEquals("repo", result) + } +} +