IJPL-248623 agent-workbench: support action prompts in ACP threads

Auth-required ACP failures need to present real runtime actions inside the thread transcript, not only text or hard-coded IDE actions. Add an action prompt transcript entry, keep auth-required threads sendable, and let the thread screen render prompt buttons that either invoke an IDE action or delegate to the owning runtime sender.

This gives ACP integrations a typed path for buttons such as authentication choices while keeping the engine UI independent from ACP-specific implementation details.

GitOrigin-RevId: a9fb023d56faf0a75a5b1dd793ba1dc44b808358
This commit is contained in:
Sergey Ignatov
2026-07-09 18:59:27 +00:00
committed by intellij-monorepo-bot
parent 8ec01bb649
commit 5b6c3cb096
6 changed files with 254 additions and 4 deletions
@@ -10,6 +10,7 @@ acp.screen.row.command=COMMAND
acp.screen.row.plan=PLAN
acp.screen.row.diff=DIFF
acp.screen.row.context=CONTEXT
acp.screen.row.authorization=AUTHORIZATION
acp.screen.row.status={0} - {1}
acp.screen.approval.status=Approval: {0}
acp.screen.message.streaming=streaming
@@ -1,6 +1,7 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.engine.core
import com.intellij.openapi.util.NlsSafe
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
@@ -136,6 +137,25 @@ data class ThreadContextCompaction(
override val updatedAt: Long = 0L,
) : ThreadTranscriptEntry
@Serializable
data class ThreadActionPromptButton(
val id: String,
val text: @NlsSafe String,
val actionId: String? = null,
val actionKind: String? = null,
val authMethodId: String? = null,
val description: @NlsSafe String? = null,
)
@Serializable
data class ThreadActionPrompt(
override val id: String,
val title: @NlsSafe String,
val message: @NlsSafe String? = null,
val buttons: List<ThreadActionPromptButton> = emptyList(),
override val updatedAt: Long = 0L,
) : ThreadTranscriptEntry
@Serializable
data class ThreadUsage(
val inputTokens: Long? = null,
@@ -231,7 +251,7 @@ fun reduce(state: ThreadProjection, event: ThreadEventEnvelope): ThreadProjectio
return when (event.type) {
ThreadEventType.ThreadCreated -> withSeq.withThreadCreated(payload, event.timestamp)
ThreadEventType.RuntimeSessionBound -> withSeq.withRuntimeBinding(payload, event.timestamp)
ThreadEventType.ThreadStarted -> withSeq.withStatus(ThreadStatus.Running, event.timestamp)
ThreadEventType.ThreadStarted -> withSeq.withThreadStarted(event.timestamp)
ThreadEventType.MessageDelta -> withSeq.withMessageDelta(payload, event.timestamp)
ThreadEventType.MessageCompleted -> withSeq.withMessageCompleted(payload, event.timestamp)
ThreadEventType.ToolCallStarted -> withSeq.withToolCallStarted(payload, event.timestamp)
@@ -262,7 +282,7 @@ fun reduce(state: ThreadProjection, event: ThreadEventEnvelope): ThreadProjectio
),
)
ThreadEventType.ThreadFailed -> withSeq.withStatus(ThreadStatus.Failed, event.timestamp)
ThreadEventType.ThreadDisconnected -> withSeq.withStatus(ThreadStatus.Disconnected, event.timestamp)
ThreadEventType.ThreadDisconnected -> withSeq.withDisconnected(payload, event.timestamp)
ThreadEventType.ThreadCancelled -> withSeq.withStatus(ThreadStatus.Cancelled, event.timestamp)
else -> withSeq
}
@@ -275,12 +295,34 @@ fun replay(seed: ThreadProjection, events: Iterable<ThreadEventEnvelope>): Threa
private fun ThreadProjection.withStatus(status: ThreadStatus, at: Long): ThreadProjection =
copy(thread = thread.copy(status = status, updatedAt = at))
private fun ThreadProjection.withThreadStarted(at: Long): ThreadProjection {
val next = withStatus(ThreadStatus.Running, at)
val transcript = next.transcript.filterNot { it is ThreadActionPrompt }
return if (transcript.size == next.transcript.size) next else next.copy(transcript = transcript)
}
private fun ThreadProjection.withThreadUpdated(at: Long): ThreadProjection =
copy(thread = thread.copy(updatedAt = at))
private fun ThreadProjection.withWaitingStatus(at: Long): ThreadProjection =
withStatus(if (pendingApprovals > 0) ThreadStatus.WaitingForApproval else ThreadStatus.WaitingForUser, at)
private fun ThreadProjection.withDisconnected(payload: JsonObject, at: Long): ThreadProjection {
val authRequired = payload.boolean("authRequired") == true
val next = withStatus(if (authRequired) ThreadStatus.WaitingForUser else ThreadStatus.Disconnected, at)
if (!authRequired) return next
val title = payload.string("title") ?: return next
val prompt = ThreadActionPrompt(
id = payload.string("promptId") ?: "auth-required",
title = title,
message = payload.string("message") ?: payload.string("details")?.takeIf { it != title },
buttons = payload.buttons(),
updatedAt = at,
)
val transcript = next.transcript.filterNot { it is ThreadActionPrompt && it.id == prompt.id } + prompt
return next.copy(transcript = transcript).withThreadUpdated(at)
}
private fun ThreadProjection.withThreadCreated(payload: JsonObject, at: Long): ThreadProjection {
val created = copy(
thread = thread.copy(
@@ -631,6 +673,24 @@ private fun JsonObject.array(key: String): JsonArray? = this[key]?.let {
runCatching { it.jsonArray }.getOrNull()
}
private fun JsonObject.buttons(): List<ThreadActionPromptButton> {
return array("buttons")?.mapIndexedNotNull { index, element ->
val button = runCatching { element.jsonObject }.getOrNull() ?: return@mapIndexedNotNull null
val text = button.string("text") ?: return@mapIndexedNotNull null
val actionId = button.string("actionId")
val actionKind = button.string("actionKind")
if (actionId == null && actionKind == null) return@mapIndexedNotNull null
ThreadActionPromptButton(
id = button.string("id") ?: actionId ?: button.string("authMethodId") ?: "button:$index",
text = text,
actionId = actionId,
actionKind = actionKind,
authMethodId = button.string("authMethodId"),
description = button.string("description"),
)
}.orEmpty()
}
private inline fun <reified T : Enum<T>> JsonObject.enum(key: String): T? {
val raw = string(key) ?: return null
return enumValues<T>().firstOrNull { it.name == raw }
@@ -1,6 +1,7 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.engine.platform
import com.intellij.agent.workbench.engine.core.ThreadActionPromptButton
import com.intellij.agent.workbench.engine.core.ThreadId
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.extensions.ExtensionPointName
@@ -21,6 +22,9 @@ interface EnginePromptSender {
/** Sends [text] as the next user prompt in [threadId]; connects the runtime session on first use. */
fun sendPrompt(project: Project, threadId: ThreadId, text: String)
/** Handles a runtime-specific action prompt button rendered in the thread transcript. */
fun handleActionPromptButton(project: Project, threadId: ThreadId, button: ThreadActionPromptButton): Boolean = false
companion object {
private val EP = ExtensionPointName<EnginePromptSender>("com.intellij.agent.workbench.engine.promptSender")
@@ -2,6 +2,8 @@
package com.intellij.agent.workbench.engine.ui
import com.intellij.agent.workbench.engine.core.RuntimeKind
import com.intellij.agent.workbench.engine.core.ThreadActionPrompt
import com.intellij.agent.workbench.engine.core.ThreadActionPromptButton
import com.intellij.agent.workbench.engine.core.ThreadEventEnvelope
import com.intellij.agent.workbench.engine.core.ThreadCommand
import com.intellij.agent.workbench.engine.core.ThreadContextCompaction
@@ -19,11 +21,20 @@ import com.intellij.agent.workbench.engine.platform.EngineProjectService
import com.intellij.agent.workbench.engine.platform.EnginePromptSender
import com.intellij.agent.workbench.engine.platform.EngineRuntimeConnector
import com.intellij.agent.workbench.engine.platform.EngineUnreadTracker
import com.intellij.ide.setToolTipText
import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.ActionPlaces
import com.intellij.openapi.actionSystem.ActionUiKind
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.CommonDataKeys
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.actionSystem.ex.ActionUtil
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.NlsSafe
import com.intellij.openapi.util.text.HtmlChunk
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBScrollPane
import com.intellij.ui.components.JBTextArea
@@ -34,6 +45,7 @@ import com.intellij.util.ui.UIUtil
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Dimension
import java.awt.FlowLayout
import java.awt.Font
import java.awt.Point
import java.awt.Rectangle
@@ -44,6 +56,7 @@ import java.awt.event.KeyAdapter
import java.awt.event.KeyEvent
import javax.swing.Box
import javax.swing.BoxLayout
import javax.swing.JButton
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.Scrollable
@@ -423,7 +436,8 @@ internal class AgentAcpThreadScreen(
val manualTailPauseActive = isManualTranscriptTailPauseActive()
val followTailBefore = followTranscriptTail
val keepFollowingTailAcrossLayoutDrift = followTailBefore && !manualTailPauseActive && scrollState.isWithinTailDrift
val shouldStickToBottom = previousComponents == 0 || latestIsUserMessage || (atBottom && !manualTailPauseActive) || keepFollowingTailAcrossLayoutDrift
val shouldStickToBottom =
previousComponents == 0 || latestIsUserMessage || (atBottom && !manualTailPauseActive) || keepFollowingTailAcrossLayoutDrift
val reason = when {
previousComponents == 0 -> "initial-or-empty-panel"
latestIsUserMessage -> "latest-entry-is-user-message"
@@ -651,6 +665,7 @@ internal class AgentAcpThreadScreen(
is ThreadPlan -> "plan(id=$id, complete=$complete, items=${items.size}, updatedAt=$updatedAt)"
is ThreadFileDiff -> "diff(id=$id, status=$status, newTextLength=${newText?.length ?: 0}, updatedAt=$updatedAt)"
is ThreadContextCompaction -> "context(id=$id, summaryLength=${summary?.length ?: 0}, updatedAt=$updatedAt)"
is ThreadActionPrompt -> "actionPrompt(id=$id, buttons=${buttons.size}, updatedAt=$updatedAt)"
}
private fun ThreadProjection.debugSummary(): String =
@@ -671,6 +686,7 @@ internal class AgentAcpThreadScreen(
is ThreadPlan -> planRow(entry)
is ThreadFileDiff -> diffRow(entry)
is ThreadContextCompaction -> contextRow(entry)
is ThreadActionPrompt -> actionPromptRow(entry)
}
private fun messageRow(message: ThreadMessage): JComponent {
@@ -791,6 +807,91 @@ internal class AgentAcpThreadScreen(
)
}
private fun actionPromptRow(prompt: ThreadActionPrompt): JComponent {
val row = TranscriptRowPanel().apply {
isOpaque = false
alignmentX = LEFT_ALIGNMENT
}
val roleLabel = JBLabel(EngineBundle.message("acp.screen.row.authorization")).apply {
font = JBFont.small().asBold()
foreground = UIUtil.getContextHelpForeground()
border = JBUI.Borders.emptyBottom(2)
}
val body = JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
isOpaque = false
add(roleLabel)
add(rowTextArea(prompt.title, monospace = false))
prompt.message?.takeIf { it.isNotBlank() }?.let { message ->
add(Box.createVerticalStrut(2))
add(rowTextArea(message, monospace = false))
}
val buttonPanel = promptButtonPanel(prompt.buttons)
if (buttonPanel != null) {
add(buttonPanel)
}
}
row.add(body, BorderLayout.CENTER)
return row
}
private fun promptButtonPanel(buttons: List<ThreadActionPromptButton>): JComponent? {
val actionManager = ActionManager.getInstance()
val panel = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply {
isOpaque = false
alignmentX = LEFT_ALIGNMENT
border = JBUI.Borders.emptyTop(6)
}
for (button in buttons) {
val actionId = button.actionId
val actionKind = button.actionKind
val component = when {
actionId != null -> {
val action = actionManager.getAction(actionId)
if (action == null) {
LOG.warn("[$threadId] ACP action prompt skipped missing action: $actionId")
null
}
else {
promptButton(button) {
val dataContext = DataContext { dataId ->
if (CommonDataKeys.PROJECT.`is`(dataId)) project else null
}
val event = AnActionEvent.createEvent(action, dataContext, null, ActionPlaces.UNKNOWN, ActionUiKind.NONE, null)
ActionUtil.performAction(action, event)
}
}
}
actionKind != null -> {
promptButton(button) {
val sender = EnginePromptSender.forThread(project, threadId)
if (sender == null || !sender.handleActionPromptButton(project, threadId, button)) {
LOG.warn("[$threadId] ACP action prompt skipped unhandled action kind: $actionKind")
}
}
}
else -> {
LOG.warn("[$threadId] ACP action prompt skipped button without action: ${button.id}")
null
}
}
if (component != null) {
if (panel.componentCount > 0) panel.add(Box.createHorizontalStrut(JBUI.scale(6)))
panel.add(component)
}
}
return panel.takeIf { it.componentCount > 0 }
}
private fun promptButton(button: ThreadActionPromptButton, action: () -> Unit): JButton {
return JButton(button.text).apply {
button.description?.let { setToolTipText(HtmlChunk.text(it)) }
accessibleContext.accessibleName = button.text
accessibleContext.accessibleDescription = button.description
addActionListener { action() }
}
}
private fun entryRow(
label: @NlsSafe String,
title: String?,
@@ -11,6 +11,7 @@ import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionSource
import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionSourceUpdateEvent
import com.intellij.agent.workbench.engine.core.MessageRole
import com.intellij.agent.workbench.engine.core.RuntimeKind
import com.intellij.agent.workbench.engine.core.ThreadActionPrompt
import com.intellij.agent.workbench.engine.core.ThreadCommand
import com.intellij.agent.workbench.engine.core.ThreadContextCompaction
import com.intellij.agent.workbench.engine.core.ThreadFileDiff
@@ -119,7 +120,7 @@ internal class EngineSessionSource : AgentSessionSource {
else -> AgentSessionOutlineItemKind.METADATA
}
is ThreadToolCall, is ThreadCommand -> AgentSessionOutlineItemKind.TOOL_CALL
is ThreadPlan, is ThreadFileDiff, is ThreadContextCompaction -> AgentSessionOutlineItemKind.METADATA
is ThreadPlan, is ThreadFileDiff, is ThreadContextCompaction, is ThreadActionPrompt -> AgentSessionOutlineItemKind.METADATA
}
private fun outlineTitle(entry: ThreadTranscriptEntry): String = when (entry) {
@@ -129,6 +130,7 @@ internal class EngineSessionSource : AgentSessionSource {
is ThreadPlan -> entry.title ?: entry.id
is ThreadFileDiff -> entry.title ?: entry.path ?: entry.id
is ThreadContextCompaction -> entry.title ?: entry.id
is ThreadActionPrompt -> entry.title
}
private fun outlinePreview(entry: ThreadTranscriptEntry): String = when (entry) {
@@ -138,5 +140,6 @@ internal class EngineSessionSource : AgentSessionSource {
is ThreadPlan -> entry.items.joinToString(separator = "\n") { it.title }
is ThreadFileDiff -> entry.newText.orEmpty()
is ThreadContextCompaction -> entry.summary.orEmpty()
is ThreadActionPrompt -> entry.message ?: entry.buttons.joinToString(separator = "\n") { it.text }
}
}
@@ -3,6 +3,7 @@ package com.intellij.agent.workbench.engine.platform
import com.intellij.agent.workbench.engine.core.EventSource
import com.intellij.agent.workbench.engine.core.EventVisibility
import com.intellij.agent.workbench.engine.core.ThreadActionPrompt
import com.intellij.agent.workbench.engine.core.StartThreadRequest
import com.intellij.agent.workbench.engine.core.ThreadApprovalStatus
import com.intellij.agent.workbench.engine.core.ThreadCommand
@@ -14,6 +15,8 @@ import com.intellij.agent.workbench.engine.core.ThreadToolCall
import com.intellij.agent.workbench.engine.core.RuntimeKind
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.addJsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.assertj.core.api.Assertions.assertThat
@@ -314,6 +317,84 @@ class EngineEventStoreTest {
assertThat(projection.messages.single().text).isEqualTo("hello")
}
@Test
fun `auth-required disconnected event shows an action prompt and keeps the thread sendable`(@TempDir dir: Path) {
val store = JsonlEventStore(dir)
store.append(
threadId, EventSource.Runtime, ThreadEventType.ThreadDisconnected,
buildJsonObject {
put("authRequired", true)
put("promptId", "auth-required")
put("title", "Authentication required")
put("message", "Sign in, then send the message again.")
put("details", "Authentication is required before this operation can be performed.")
put("buttons", buildJsonArray {
addJsonObject {
put("id", "acp-auth-jetbrains-account")
put("text", "Junie Account")
put("actionKind", "acpAuthenticate")
put("authMethodId", "jetbrains-account")
put("description", "Log in via Junie Account in a browser.")
}
})
},
)
val projection = store.projection(threadId)
assertThat(projection.thread.status).isEqualTo(ThreadStatus.WaitingForUser)
val prompt = projection.transcript.single() as ThreadActionPrompt
assertThat(prompt.title).isEqualTo("Authentication required")
assertThat(prompt.message).isEqualTo("Sign in, then send the message again.")
val button = prompt.buttons.single()
assertThat(button.id).isEqualTo("acp-auth-jetbrains-account")
assertThat(button.text).isEqualTo("Junie Account")
assertThat(button.actionId).isNull()
assertThat(button.actionKind).isEqualTo("acpAuthenticate")
assertThat(button.authMethodId).isEqualTo("jetbrains-account")
assertThat(button.description).isEqualTo("Log in via Junie Account in a browser.")
}
@Test
fun `ThreadStarted clears stale action prompts`(@TempDir dir: Path) {
val store = JsonlEventStore(dir)
store.append(
threadId, EventSource.Runtime, ThreadEventType.ThreadDisconnected,
buildJsonObject {
put("authRequired", true)
put("promptId", "auth-required")
put("title", "Authentication required")
put("message", "Sign in, then send the message again.")
put("buttons", buildJsonArray {
addJsonObject {
put("id", "open-ai-assistant-settings")
put("text", "Open AI Assistant Settings")
put("actionId", "AIAssistant.LLMSettings")
}
})
},
)
assertThat(store.projection(threadId).transcript.filterIsInstance<ThreadActionPrompt>()).hasSize(1)
store.append(
threadId, EventSource.User, ThreadEventType.MessageDelta,
buildJsonObject {
put("messageId", "m1")
put("role", "User")
put("contentDelta", "hey")
},
)
store.append(
threadId, EventSource.User, ThreadEventType.MessageCompleted,
buildJsonObject { put("messageId", "m1") },
)
store.append(threadId, EventSource.Runtime, ThreadEventType.ThreadStarted)
val projection = store.projection(threadId)
assertThat(projection.thread.status).isEqualTo(ThreadStatus.Running)
assertThat(projection.transcript.filterIsInstance<ThreadActionPrompt>()).isEmpty()
assertThat(projection.messages.single().text).isEqualTo("hey")
}
@Test
fun `ThreadWaiting after disconnected recovers the thread status`(@TempDir dir: Path) {
val store = JsonlEventStore(dir)