diff --git a/plugins/agent-workbench/engine/resources/messages/EngineBundle.properties b/plugins/agent-workbench/engine/resources/messages/EngineBundle.properties index eeb8073b8662..59feb16c3509 100644 --- a/plugins/agent-workbench/engine/resources/messages/EngineBundle.properties +++ b/plugins/agent-workbench/engine/resources/messages/EngineBundle.properties @@ -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 diff --git a/plugins/agent-workbench/engine/src/core/ThreadProjection.kt b/plugins/agent-workbench/engine/src/core/ThreadProjection.kt index 86a850621a6d..5d44abee8239 100644 --- a/plugins/agent-workbench/engine/src/core/ThreadProjection.kt +++ b/plugins/agent-workbench/engine/src/core/ThreadProjection.kt @@ -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 = 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): 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 { + 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 > JsonObject.enum(key: String): T? { val raw = string(key) ?: return null return enumValues().firstOrNull { it.name == raw } diff --git a/plugins/agent-workbench/engine/src/platform/EnginePromptSender.kt b/plugins/agent-workbench/engine/src/platform/EnginePromptSender.kt index c559db74c358..d8880f454765 100644 --- a/plugins/agent-workbench/engine/src/platform/EnginePromptSender.kt +++ b/plugins/agent-workbench/engine/src/platform/EnginePromptSender.kt @@ -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("com.intellij.agent.workbench.engine.promptSender") diff --git a/plugins/agent-workbench/engine/src/ui/AgentAcpThreadScreen.kt b/plugins/agent-workbench/engine/src/ui/AgentAcpThreadScreen.kt index 61510a4f6fb7..3f3b228b8403 100644 --- a/plugins/agent-workbench/engine/src/ui/AgentAcpThreadScreen.kt +++ b/plugins/agent-workbench/engine/src/ui/AgentAcpThreadScreen.kt @@ -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): 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?, diff --git a/plugins/agent-workbench/engine/src/ui/EngineSessionSource.kt b/plugins/agent-workbench/engine/src/ui/EngineSessionSource.kt index a106aa6a25ce..d2dbdb52b0ab 100644 --- a/plugins/agent-workbench/engine/src/ui/EngineSessionSource.kt +++ b/plugins/agent-workbench/engine/src/ui/EngineSessionSource.kt @@ -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 } } } diff --git a/plugins/agent-workbench/engine/testSrc/EngineEventStoreTest.kt b/plugins/agent-workbench/engine/testSrc/EngineEventStoreTest.kt index 80e96d8f4274..b9287b5f6f09 100644 --- a/plugins/agent-workbench/engine/testSrc/EngineEventStoreTest.kt +++ b/plugins/agent-workbench/engine/testSrc/EngineEventStoreTest.kt @@ -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()).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()).isEmpty() + assertThat(projection.messages.single().text).isEqualTo("hey") + } + @Test fun `ThreadWaiting after disconnected recovers the thread status`(@TempDir dir: Path) { val store = JsonlEventStore(dir)