Skip to content

feat(idea): ACP client integration (stdio)#537

Merged
phodal merged 1 commit intomasterfrom
feat/idea-acp-integration
Feb 4, 2026
Merged

feat(idea): ACP client integration (stdio)#537
phodal merged 1 commit intomasterfrom
feat/idea-acp-integration

Conversation

@phodal
Copy link
Owner

@phodal phodal commented Feb 2, 2026

Summary

  • Add ACP (Agent Client Protocol) client integration for the IntelliJ IDEA plugin using JSON-RPC over stdio.
  • Surface ACP as a new mode under Remote (Server/ACP toggle), streaming session/update into the existing timeline renderer.
  • Persist ACP agent command/args/env in IDEA settings.

Notes

Test plan

  • ./gradlew :mpp-idea:compileKotlin
  • ./gradlew :mpp-idea:buildPlugin
  • In IDE: Xiuper Agents -> Remote -> ACP, configure agent command/args, Start, send a prompt and verify streaming output.

Refs: #535

Summary by CodeRabbit

  • New Features
    • Added Agent Client Protocol (ACP) support for remote agents with configurable connection settings.
    • Introduced a mode selector to switch between Server and ACP remote agent implementations.
    • Added ACP connection management with command, arguments, and environment configuration options.
    • Integrated ACP timeline and streaming output visualization in the plugin interface.

Implements ACP client for the IDEA plugin and surfaces it under Remote -> ACP, streaming session updates into the existing timeline renderer.

Refs: #535
Copilot AI review requested due to automatic review settings February 2, 2026 05:31
@coderabbitai
Copy link

coderabbitai bot commented Feb 2, 2026

📝 Walkthrough

Walkthrough

This PR introduces ACP (Agent Client Protocol) integration into the IntelliJ IDEA plugin through build dependencies, extended settings for ACP configuration, new UI components and ViewModel for ACP lifecycle management, a mode selector for remote agent types, and refactoring of the main agent app to support multiple remote modes.

Changes

Cohort / File(s) Summary
Build Configuration
mpp-idea/build.gradle.kts
Adds ACP dependency (0.10.5) with kotlinx exclusions to prevent conflicts with IntelliJ's bundled versions; explicitly includes kotlinx-io-core for stdio transport support.
Settings State
mpp-idea/mpp-idea-core/src/main/kotlin/cc/unitmesh/devti/settings/AutoDevSettingsState.kt
Adds three new serialized properties (acpCommand, acpArgs, acpEnv) to store ACP configuration without affecting existing logic.
ACP Integration
mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/acp/IdeaAcpAgentViewModel.kt
Introduces core ViewModel managing ACP agent lifecycle: spawns local ACP process, establishes Protocol/Client session over stdio, handles coroutine-based IO, configuration persistence, and streams ACP updates (messages, plans, tool calls) to UI renderer.
ACP UI Component
mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/acp/IdeaAcpAgentContent.kt
Adds Compose UI for ACP session display with configuration panel (command, args, env, cwd fields), stderr tail log, timeline rendering, and connection status indicator with bidirectional state sync.
Remote Mode Selector
mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteModeSelector.kt
Introduces RemoteAgentMode enum (SERVER, ACP) and composable pill-based UI for mode selection with animated visual feedback.
Main Agent App Integration
mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt
Refactors existing remote agent tab to support dual modes (SERVER and ACP): adds lazy ViewModel initialization, unified top content branching by mode, updated input handling and processing state for both modes, and expanded disposal logic.

Sequence Diagram(s)

sequenceDiagram
    participant User as User<br/>(IDEA UI)
    participant IdeaAcpAgentContent as IdeaAcpAgentContent<br/>(UI Component)
    participant IdeaAcpAgentViewModel as IdeaAcpAgentViewModel<br/>(ViewModel)
    participant AcpProcess as Local ACP<br/>Process
    participant Protocol as ACP Protocol<br/>(Client/StdioTransport)

    User->>IdeaAcpAgentContent: Click Start
    IdeaAcpAgentContent->>IdeaAcpAgentViewModel: connect(config)
    IdeaAcpAgentViewModel->>IdeaAcpAgentViewModel: parseEnvLines() + splitArgs()
    IdeaAcpAgentViewModel->>AcpProcess: ProcessBuilder.start()
    activate AcpProcess
    IdeaAcpAgentViewModel->>Protocol: StdioTransport(stdin, stdout)
    IdeaAcpAgentViewModel->>Protocol: Protocol.initSession(ClientInfo)
    Protocol->>AcpProcess: handshake + session init
    AcpProcess-->>Protocol: session established
    Protocol->>IdeaAcpAgentViewModel: ClientSessionOperations callback
    IdeaAcpAgentViewModel->>IdeaAcpAgentContent: isConnected.emit(true)
    
    User->>IdeaAcpAgentContent: Enter prompt + Send
    IdeaAcpAgentContent->>IdeaAcpAgentViewModel: sendMessage(text)
    IdeaAcpAgentViewModel->>Protocol: send ResourceRequest/message
    Protocol->>AcpProcess: stdin stream
    AcpProcess-->>Protocol: Agent Message + Plan + ToolCall
    Protocol->>IdeaAcpAgentViewModel: handleMessage() callback
    IdeaAcpAgentViewModel->>IdeaAcpAgentViewModel: renderer.render() (converts to timeline)
    IdeaAcpAgentViewModel->>IdeaAcpAgentContent: renderer updates (StateFlow)
    IdeaAcpAgentContent->>IdeaAcpAgentContent: IdeaTimelineContent recomposes
    
    User->>IdeaAcpAgentContent: Click Stop
    IdeaAcpAgentContent->>IdeaAcpAgentViewModel: disconnect()
    IdeaAcpAgentViewModel->>Protocol: close()
    IdeaAcpAgentViewModel->>AcpProcess: destroy()
    deactivate AcpProcess
    IdeaAcpAgentViewModel->>IdeaAcpAgentContent: isConnected.emit(false)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

Poem

🐰 Hop, hop, ACP's here to stay,
Protocols dancing in stdio's way,
Agents chatting through the remote tide,
With Server and ACP running side by side,
Settings saved, timelines drawn,
A new mode selector greets the dawn!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.13% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(idea): ACP client integration (stdio)' directly and clearly summarizes the primary change—adding ACP (Agent Client Protocol) client integration to the IntelliJ IDEA plugin with stdio transport. It is concise, specific, and accurately represents the main objective of the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/idea-acp-integration

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR integrates an ACP (Agent Client Protocol) client into the IntelliJ IDEA plugin as an alternative “Remote” backend, using JSON-RPC over stdio, and persists ACP command configuration in plugin settings.

Changes:

  • Add RemoteAgentMode selector UI and wire it into the existing Remote tab so users can switch between Server and ACP modes while sharing the same input area.
  • Introduce IdeaAcpAgentViewModel plus IdeaAcpAgentContent to manage the ACP stdio process, client/session lifecycle, and render session/update streams into the existing timeline UI.
  • Extend AutoDevSettingsState and build.gradle.kts to persist ACP command/args/env settings and to pull in the ACP + kotlinx-io dependencies.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteModeSelector.kt Adds a small pill-based RemoteAgentMode selector (Server/ACP) used to toggle between remote backends in the Remote tab.
mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/acp/IdeaAcpAgentViewModel.kt Implements the ACP client integration: spawns the local ACP process over stdio, initializes the protocol and session, streams events into JewelRenderer, and defines minimal client operations with fs/terminal disabled.
mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/acp/IdeaAcpAgentContent.kt Provides the ACP configuration and status UI (command/args/cwd/env, connection indicator, stderr tail) and hooks it to the shared timeline view.
mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt Wires ACP mode into the main toolwindow: creates and disposes the new ACP view model, adds the mode selector, switches the top content between server and ACP, and routes input/abort actions to the appropriate backend.
mpp-idea/mpp-idea-core/src/main/kotlin/cc/unitmesh/devti/settings/AutoDevSettingsState.kt Adds persisted ACP configuration fields for command, args, and environment text.
mpp-idea/build.gradle.kts Declares the ACP library dependency (with explicit kotlinx exclusions) and adds kotlinx-io-core to support the stdio transport.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +32 to +33
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These Json-related imports (JsonObject, JsonPrimitive) are not used in this file and can be removed to reduce noise and avoid unused-import warnings.

Suggested change
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive

Copilot uses AI. Check for mistakes.
Comment on lines +50 to +80
class IdeaAcpAgentViewModel(
val project: Project,
private val coroutineScope: CoroutineScope,
) : Disposable {
val renderer = JewelRenderer()

private val _isExecuting = MutableStateFlow(false)
val isExecuting: StateFlow<Boolean> = _isExecuting.asStateFlow()

private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()

private val _connectionError = MutableStateFlow<String?>(null)
val connectionError: StateFlow<String?> = _connectionError.asStateFlow()

private val _stderrTail = MutableStateFlow<List<String>>(emptyList())
val stderrTail: StateFlow<List<String>> = _stderrTail.asStateFlow()

private var process: Process? = null
private var protocol: Protocol? = null
private var client: Client? = null
private var session: ClientSession? = null

private var stderrJob: Job? = null
private var connectJob: Job? = null
private var currentPromptJob: Job? = null

private val receivedAnyAgentChunk = AtomicBoolean(false)
private val inThoughtStream = AtomicBoolean(false)

fun loadConfigFromSettings(): AcpAgentConfig {
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new IdeaAcpAgentViewModel adds non-trivial logic for process management, ACP session handling, and helpers like splitArgs/parseEnvLines but currently has no dedicated tests, whereas the analogous remote agent view model is covered by tests (e.g. mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModelTest.kt). Adding unit tests for connection failure paths, prompt flow handling, and the argument/env parsing helpers would improve confidence and prevent regressions in this integration.

Copilot uses AI. Check for mistakes.
@augmentcode
Copy link

augmentcode bot commented Feb 2, 2026

🤖 Augment PR Summary

Summary: Adds ACP (Agent Client Protocol) client support to the IntelliJ IDEA plugin via JSON-RPC over stdio.

Changes:

  • Add ACP + kotlinx-io dependencies to the IDEA module build
  • Persist ACP agent command/args/env in AutoDevSettingsState
  • Introduce IdeaAcpAgentViewModel to spawn a local ACP agent process and manage sessions/prompts
  • Add IdeaAcpAgentContent UI to configure/start/stop ACP and render streaming session/update events in the existing timeline
  • Extend the Remote toolwindow with a Server/ACP mode toggle, routing send/abort to the selected backend
  • Adjust toolwindow lifecycle to dispose view models when the toolwindow is disposed (rather than on tab switch)

Technical Notes: Uses com.agentclientprotocol:acp stdio transport, disables fs/terminal capabilities, and denies permission requests to match Milestone 1 (#535).

🤖 Was this summary useful? React with 👍 or 👎

Copy link

@augmentcode augmentcode bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review completed. 4 suggestions posted.

Fix All in Augment

Comment augment review to trigger a new review at any time.

pb.redirectError(ProcessBuilder.Redirect.PIPE)
pb.environment().putAll(env)

acpLogger.info("Starting ACP agent process: ${pb.command().joinToString(" ")} (cwd=$cwd)")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

acpLogger.info("Starting ACP agent process: ...") logs the full command line, which may accidentally include tokens/keys passed via args; consider redacting or logging only the executable name. (Guideline: no_sensitive_logging)

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

fun cancelTask() {
currentPromptJob?.cancel(CancellationException("Cancelled by user"))
currentPromptJob = null
_isExecuting.value = false
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cancelTask() sets _isExecuting to false immediately, which can let the UI start a new prompt while the previous ACP turn is still cancelling and potentially interleave session state.

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

}
withContext(Dispatchers.IO) {
try {
p.waitFor()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

disconnectInternal() calls p.waitFor() without a timeout; if the spawned agent ignores termination, disconnect/toolwindow disposal could block indefinitely on an IO thread.

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

buf.append(ch)
escape = false
}
ch == '\\' -> {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

splitArgs() treats every \\ as an escape character, which can break common Windows paths/arguments (e.g., C:\\Program Files\\...) even when the user didn’t intend escaping.

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In
`@mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/acp/IdeaAcpAgentViewModel.kt`:
- Around line 434-447: Replace the best-effort p.destroy() close with a forcible
shutdown: call p.destroyForcibly() and then wait for termination with a bounded
wait (e.g., p.waitFor(timeoutMillis, TimeUnit.MILLISECONDS)) inside the
withContext(Dispatchers.IO) block to avoid indefinite blocking; keep the
null-check on process/p, handle/ignore exceptions as before, and log or handle
the timeout case if waitFor returns false so cleanup is reliable in
IdeaAcpAgentViewModel (references: the process variable, local p, and the
withContext(Dispatchers.IO) wait block).
- Around line 230-239: The code currently force-unwraps session inside
currentPromptJob's coroutine (session!!) which can race with disconnect();
capture the session into a local val (e.g., val activeSession = session) before
launching the coroutine and use that local (activeSession) when calling
session.prompt so the coroutine uses a stable non-null reference; ensure you
handle the case where the captured session is null by aborting the
launch/returning early to avoid NPEs and keep disconnect() semantics intact.
🧹 Nitpick comments (2)
mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/acp/IdeaAcpAgentContent.kt (1)

41-55: Multiple flow collectors with same key.

Multiple IdeaLaunchedEffect blocks share the same key (viewModel.renderer or viewModel), which could lead to unexpected behavior if the effect is restarted. Consider combining related collectors or using distinct keys.

♻️ Optional: Combine related collectors
-    IdeaLaunchedEffect(viewModel.renderer) {
-        viewModel.renderer.timeline.collect { timeline = it }
-    }
-    IdeaLaunchedEffect(viewModel.renderer) {
-        viewModel.renderer.currentStreamingOutput.collect { streamingOutput = it }
-    }
+    IdeaLaunchedEffect(viewModel.renderer) {
+        launch { viewModel.renderer.timeline.collect { timeline = it } }
+        launch { viewModel.renderer.currentStreamingOutput.collect { streamingOutput = it } }
+    }
mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/acp/IdeaAcpAgentViewModel.kt (1)

450-460: Frequent list allocation in stderr tail collection.

Each line read creates a new list via _stderrTail.value + line. For high-volume stderr output, this could cause memory pressure. Consider using a more efficient approach.

♻️ Optional: Use a bounded queue for efficiency
-    private fun readStderrTail(p: Process, maxLines: Int = 200) {
+    private fun readStderrTail(p: Process, maxLines: Int = 200) {
+        val buffer = ArrayDeque<String>(maxLines)
         try {
             BufferedReader(InputStreamReader(p.errorStream)).useLines { lines ->
                 lines.forEach { line ->
-                    _stderrTail.value = (_stderrTail.value + line).takeLast(maxLines)
+                    if (buffer.size >= maxLines) buffer.removeFirst()
+                    buffer.addLast(line)
+                    _stderrTail.value = buffer.toList()
                 }
             }
         } catch (e: Exception) {
             // Ignore
         }
     }

Comment on lines +230 to +239
currentPromptJob = coroutineScope.launch(Dispatchers.IO) {
try {
receivedAnyAgentChunk.set(false)
inThoughtStream.set(false)

val flow = session!!.prompt(
listOf(ContentBlock.Text(text, Annotations(), JsonNull)),
JsonNull
)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential race condition with session access.

The session!! force unwrap on line 235 could cause an NPE if disconnect() is called concurrently between the null check on line 220 and the access on line 235. Consider capturing the session reference locally.

🛡️ Proposed fix to avoid race condition
     fun sendMessage(text: String) {
         if (_isExecuting.value) return
-        if (!_isConnected.value || session == null) {
+        val currentSession = session
+        if (!_isConnected.value || currentSession == null) {
             renderer.renderError("ACP agent is not connected. Please start it first.")
             return
         }

         renderer.clearError()
         renderer.addUserMessage(text)

         _isExecuting.value = true
         currentPromptJob?.cancel()
         currentPromptJob = coroutineScope.launch(Dispatchers.IO) {
             try {
                 receivedAnyAgentChunk.set(false)
                 inThoughtStream.set(false)

-                val flow = session!!.prompt(
+                val flow = currentSession.prompt(
                     listOf(ContentBlock.Text(text, Annotations(), JsonNull)),
                     JsonNull
                 )
🤖 Prompt for AI Agents
In
`@mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/acp/IdeaAcpAgentViewModel.kt`
around lines 230 - 239, The code currently force-unwraps session inside
currentPromptJob's coroutine (session!!) which can race with disconnect();
capture the session into a local val (e.g., val activeSession = session) before
launching the coroutine and use that local (activeSession) when calling
session.prompt so the coroutine uses a stable non-null reference; ensure you
handle the case where the captured session is null by aborting the
launch/returning early to avoid NPEs and keep disconnect() semantics intact.

Comment on lines +434 to +447
val p = process
process = null
if (p != null) {
try {
p.destroy()
} catch (_: Exception) {
}
withContext(Dispatchers.IO) {
try {
p.waitFor()
} catch (_: Exception) {
}
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Prefer destroyForcibly() for process termination.

Using destroy() may not reliably terminate the child process on all platforms. Consider using destroyForcibly() followed by waitFor() with a timeout to ensure cleanup.

🛡️ Proposed fix for reliable process termination
         val p = process
         process = null
         if (p != null) {
             try {
-                p.destroy()
+                p.destroyForcibly()
             } catch (_: Exception) {
             }
             withContext(Dispatchers.IO) {
                 try {
-                    p.waitFor()
+                    p.waitFor(5, java.util.concurrent.TimeUnit.SECONDS)
                 } catch (_: Exception) {
                 }
             }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val p = process
process = null
if (p != null) {
try {
p.destroy()
} catch (_: Exception) {
}
withContext(Dispatchers.IO) {
try {
p.waitFor()
} catch (_: Exception) {
}
}
}
val p = process
process = null
if (p != null) {
try {
p.destroyForcibly()
} catch (_: Exception) {
}
withContext(Dispatchers.IO) {
try {
p.waitFor(5, java.util.concurrent.TimeUnit.SECONDS)
} catch (_: Exception) {
}
}
}
🤖 Prompt for AI Agents
In
`@mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/acp/IdeaAcpAgentViewModel.kt`
around lines 434 - 447, Replace the best-effort p.destroy() close with a
forcible shutdown: call p.destroyForcibly() and then wait for termination with a
bounded wait (e.g., p.waitFor(timeoutMillis, TimeUnit.MILLISECONDS)) inside the
withContext(Dispatchers.IO) block to avoid indefinite blocking; keep the
null-check on process/p, handle/ignore exceptions as before, and log or handle
the timeout case if waitFor returns false so cleanup is reliable in
IdeaAcpAgentViewModel (references: the process variable, local p, and the
withContext(Dispatchers.IO) wait block).

@phodal phodal merged commit d386c0e into master Feb 4, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

Comments