mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
Merge pull request #13240 from Kilo-Org/plan-jetbrains-workflows-parity
feat(jetbrains): add workflows settings page
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": minor
|
||||
---
|
||||
|
||||
Support opening, editing, and deleting workflows from JetBrains settings.
|
||||
+13
@@ -7,6 +7,7 @@ import ai.kilocode.rpc.KiloAgentBehaviorRpcApi
|
||||
import ai.kilocode.rpc.dto.AgentDetailDto
|
||||
import ai.kilocode.rpc.dto.AgentCreateDto
|
||||
import ai.kilocode.rpc.dto.CommandDto
|
||||
import ai.kilocode.rpc.dto.CommandFileDto
|
||||
import ai.kilocode.rpc.dto.McpConfigDto
|
||||
import ai.kilocode.rpc.dto.McpServerConfigDto
|
||||
import ai.kilocode.rpc.dto.McpStatusDto
|
||||
@@ -39,6 +40,11 @@ class KiloAgentBehaviorService internal constructor(
|
||||
|
||||
suspend fun commands(directory: String): List<CommandDto> = safe(emptyList()) { call { commands(directory) } }
|
||||
|
||||
suspend fun loadCommandFiles(directory: String): List<CommandFileDto> = call { commandFiles(directory) }
|
||||
|
||||
suspend fun refreshCommandFiles(directory: String, fallback: List<CommandFileDto>): List<CommandFileDto> =
|
||||
safe(fallback) { call { commandFiles(directory) } }
|
||||
|
||||
suspend fun mcpStatus(directory: String): List<McpStatusDto> = try {
|
||||
LOG.info("mcp status: requesting dir=$directory")
|
||||
call { mcpStatus(directory) }.also { LOG.info("mcp status: received dir=$directory count=${it.size}") }
|
||||
@@ -59,6 +65,13 @@ class KiloAgentBehaviorService internal constructor(
|
||||
suspend fun saveSkills(directory: String, edits: Map<String, String>): Boolean =
|
||||
safe(false) { call { saveSkills(directory, edits) } }
|
||||
|
||||
suspend fun removeCommand(directory: String, location: String): Boolean = safe(false) { call { removeCommand(directory, location) } }
|
||||
|
||||
suspend fun reloadCommands(directory: String): Boolean = safe(false) { call { reloadCommands(directory) } }
|
||||
|
||||
suspend fun saveCommands(directory: String, edits: Map<String, String>): Boolean =
|
||||
safe(false) { call { saveCommands(directory, edits) } }
|
||||
|
||||
suspend fun removeAgent(directory: String, name: String): Boolean = safe(false) { call { removeAgent(directory, name) } }
|
||||
|
||||
suspend fun createAgent(directory: String, input: AgentCreateDto): Boolean = safe(false) { call { createAgent(directory, input) } }
|
||||
|
||||
+1
@@ -27,6 +27,7 @@ class AgentBehaviorConfigurable : SearchableConfigurable {
|
||||
KiloBundle.message("settings.agentBehavior.agents.displayName") to AgentsConfigurable.ID,
|
||||
KiloBundle.message("settings.agentBehavior.mcp.displayName") to McpConfigurable.ID,
|
||||
KiloBundle.message("settings.agentBehavior.skills.displayName") to SkillsConfigurable.ID,
|
||||
KiloBundle.message("settings.agentBehavior.workflows.displayName") to WorkflowsConfigurable.ID,
|
||||
KiloBundle.message("settings.agentBehavior.rules.displayName") to RulesConfigurable.ID,
|
||||
).forEach { (label, id) ->
|
||||
panel.next(ActionLink(label) { e ->
|
||||
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
package ai.kilocode.client.settings.agents
|
||||
|
||||
import ai.kilocode.client.KiloNotifications
|
||||
import ai.kilocode.client.app.KiloAgentBehaviorService
|
||||
import ai.kilocode.client.app.KiloWorkspaceService
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.settings.base.SettingsContentField
|
||||
import ai.kilocode.client.settings.base.SettingsDraftPage
|
||||
import ai.kilocode.client.settings.base.SettingsDraftState
|
||||
import ai.kilocode.client.settings.base.SettingsListPanel
|
||||
import ai.kilocode.client.settings.base.SettingsMessageException
|
||||
import ai.kilocode.client.settings.base.settingsContentScroll
|
||||
import ai.kilocode.client.settings.base.settingsEditorFileType
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.list.ActiveListBadge
|
||||
import ai.kilocode.client.ui.list.ActiveListCell
|
||||
import ai.kilocode.client.ui.list.ActiveListConfig
|
||||
import ai.kilocode.client.ui.list.ActiveListItem
|
||||
import ai.kilocode.client.ui.list.ActiveListSelection
|
||||
import ai.kilocode.log.KiloLog
|
||||
import ai.kilocode.rpc.dto.CommandFileDto
|
||||
import com.intellij.CommonBundle
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.openapi.application.EDT
|
||||
import com.intellij.openapi.application.ModalityState
|
||||
import com.intellij.openapi.application.asContextElement
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.editor.event.DocumentEvent
|
||||
import com.intellij.openapi.editor.event.DocumentListener
|
||||
import com.intellij.openapi.fileTypes.FileType
|
||||
import com.intellij.openapi.ui.DialogWrapper
|
||||
import com.intellij.openapi.ui.Messages
|
||||
import com.intellij.ui.components.JBScrollPane
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.ScrollPaneConstants
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
private val workflowEdt = Dispatchers.EDT + ModalityState.any().asContextElement()
|
||||
|
||||
class WorkflowsConfigurable : AgentBehaviorConfigurableBase<JComponent>() {
|
||||
override fun getId(): String = ID
|
||||
override fun getDisplayName(): String = KiloBundle.message("settings.agentBehavior.workflows.displayName")
|
||||
override fun create(cs: CoroutineScope, dir: String): JComponent = WorkflowsSettingsUi(cs, dir)
|
||||
override fun update(ui: JComponent, dir: String) {
|
||||
(ui as? WorkflowsSettingsUi)?.setDirectory(dir)
|
||||
}
|
||||
override fun scrollReadyShell() = false
|
||||
|
||||
companion object { const val ID = "ai.kilocode.jetbrains.settings.agentBehavior.workflows" }
|
||||
}
|
||||
|
||||
internal class WorkflowsSettingsUi(
|
||||
scope: CoroutineScope,
|
||||
dir: String,
|
||||
private val edit: (CommandFileDto, Boolean) -> WorkflowEditDialogHandle = ::WorkflowEditDialog,
|
||||
) : SettingsListPanel(scope, ActiveListConfig.Equal.copy(tooltip = false)), SettingsDraftPage {
|
||||
private var dir = dir
|
||||
private var flows = emptyMap<String, CommandFileDto>()
|
||||
private val state = SettingsDraftState(workflowsDraft(), ::saved)
|
||||
private var draft: WorkflowsDraft
|
||||
get() = state.draft
|
||||
set(value) {
|
||||
state.draft = value
|
||||
}
|
||||
|
||||
init {
|
||||
start()
|
||||
setCenter(workflowsScroll())
|
||||
}
|
||||
|
||||
fun setDirectory(value: String) {
|
||||
if (value == dir) return
|
||||
dir = value
|
||||
reload()
|
||||
}
|
||||
|
||||
override suspend fun fetch(): List<ActiveListItem> {
|
||||
val items = withTimeoutOrNull(WORKFLOW_LOAD_TIMEOUT_MS) {
|
||||
service<KiloAgentBehaviorService>().loadCommandFiles(dir)
|
||||
} ?: throw SettingsMessageException(KiloBundle.message("settings.agentBehavior.workflows.load.timeout"))
|
||||
withContext(workflowEdt) {
|
||||
val dirty = state.modified()
|
||||
val edit = draft
|
||||
state.accept(workflowsDraft())
|
||||
if (dirty) draft = state.draft.copy(edited = edit.edited, deleted = edit.deleted)
|
||||
flows = items.associateBy { key(it) }
|
||||
}
|
||||
LOG.info("workflows settings fetch dir=$dir total=${items.size}")
|
||||
return rows(items)
|
||||
}
|
||||
|
||||
override fun onCell(key: String, cellId: String) {
|
||||
val flow = flows[key] ?: return
|
||||
when (cellId) {
|
||||
OPEN_CELL -> open(flow)
|
||||
EDIT_CELL -> edit(flow)
|
||||
DELETE_CELL -> remove(flow)
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchPlaceholder() = KiloBundle.message("settings.agentBehavior.workflows.search")
|
||||
|
||||
override fun emptyText() = KiloBundle.message("settings.agentBehavior.workflows.empty")
|
||||
|
||||
override fun modified(): Boolean = state.modified()
|
||||
|
||||
override fun resetDraft() {
|
||||
state.reset()
|
||||
view.update(rows())
|
||||
clearProgress()
|
||||
}
|
||||
|
||||
override fun applyDraft() {
|
||||
val token = state.start() ?: return
|
||||
val fallback = workflowFallback(token.target)
|
||||
if (!launch("apply") { id ->
|
||||
val target = token.target
|
||||
var failed: String? = null
|
||||
val behavior = service<KiloAgentBehaviorService>()
|
||||
LOG.info("workflows settings apply start dir=$dir edited=${target.edited.size} deleted=${target.deleted.size}")
|
||||
if (target.edited.isNotEmpty() && !behavior.saveCommands(dir, target.edited)) {
|
||||
failed = KiloBundle.message("settings.agentBehavior.save.failed")
|
||||
}
|
||||
if (failed == null) {
|
||||
for (location in target.deleted) {
|
||||
if (!behavior.removeCommand(dir, location)) {
|
||||
failed = KiloBundle.message("settings.agentBehavior.workflows.delete.failed")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
val reloaded = if (failed == null) behavior.reloadCommands(dir) else true
|
||||
val items = behavior.refreshCommandFiles(dir, fallback)
|
||||
withContext(workflowEdt) {
|
||||
if (!active(id)) {
|
||||
if (failed == null) KiloNotifications.info(KiloBundle.message("settings.agentBehavior.workflows.saved.notification"))
|
||||
else KiloNotifications.error(failed)
|
||||
return@withContext
|
||||
}
|
||||
if (failed == null) {
|
||||
flows = items.associateBy { key(it) }
|
||||
state.complete(token, workflowsDraft())
|
||||
view.update(rows(items))
|
||||
if (reloaded) clearProgress() else showProgress(KiloBundle.message("settings.agentBehavior.workflows.reload.blocked"))
|
||||
LOG.info("workflows settings apply succeeded dir=$dir")
|
||||
} else {
|
||||
state.fail(token, failed)
|
||||
view.update(rows(items))
|
||||
showError(failed)
|
||||
LOG.warn("workflows settings apply failed dir=$dir message=$failed")
|
||||
}
|
||||
setBusy(false)
|
||||
}
|
||||
}) {
|
||||
val failed = KiloBundle.message("settings.agentBehavior.save.failed")
|
||||
state.fail(token, failed)
|
||||
showError(failed)
|
||||
return
|
||||
}
|
||||
showProgress(KiloBundle.message("settings.agentBehavior.saving"))
|
||||
}
|
||||
|
||||
private fun workflowsScroll() = JBScrollPane(view).apply {
|
||||
border = null
|
||||
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
|
||||
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
|
||||
}
|
||||
|
||||
private fun rows(items: List<CommandFileDto> = flows.values.toList()): List<ActiveListItem> = items.mapNotNull { flow ->
|
||||
if (flow.location in draft.deleted) return@mapNotNull null
|
||||
item(flow)
|
||||
}
|
||||
|
||||
private fun workflowFallback(target: WorkflowsDraft): List<CommandFileDto> = flows.values.mapNotNull { flow ->
|
||||
if (flow.location in target.deleted) return@mapNotNull null
|
||||
target.edited[flow.location]?.let { flow.copy(content = it) } ?: flow
|
||||
}
|
||||
|
||||
private fun item(flow: CommandFileDto) = object : ActiveListItem {
|
||||
override val key = key(flow)
|
||||
override val title = "/${flow.name}"
|
||||
override val note = flow.location.takeUnless { builtin(flow) }
|
||||
override val description = flow.description
|
||||
override val doubleClick = EDIT_CELL
|
||||
override val badges = listOf(
|
||||
ActiveListBadge(KiloBundle.message("settings.agentBehavior.badge.builtin"), UiStyle.Badge.Secondary),
|
||||
).takeIf { builtin(flow) } ?: emptyList()
|
||||
override val cells = listOfNotNull(
|
||||
ActiveListCell(
|
||||
OPEN_CELL,
|
||||
KiloBundle.message("settings.agentBehavior.workflows.openInEditor"),
|
||||
primary = true,
|
||||
).takeIf { flow.editable },
|
||||
ActiveListCell(
|
||||
EDIT_CELL,
|
||||
KiloBundle.message(if (flow.editable) "settings.agentBehavior.edit" else "common.open"),
|
||||
primary = !flow.editable,
|
||||
),
|
||||
ActiveListCell(
|
||||
DELETE_CELL,
|
||||
KiloBundle.message("common.delete"),
|
||||
icon = AllIcons.Actions.GC,
|
||||
iconOnly = true,
|
||||
).takeIf { flow.editable },
|
||||
)
|
||||
}
|
||||
|
||||
private fun edit(flow: CommandFileDto) {
|
||||
val current = flow.copy(content = content(flow))
|
||||
val dialog = edit(current, flow.editable)
|
||||
if (!flow.editable) {
|
||||
dialog.showAndGet()
|
||||
return
|
||||
}
|
||||
if (!dialog.showAndGet()) return
|
||||
state.update { copy(edited = edited + (flow.location to dialog.content())) }
|
||||
view.update(rows(), ActiveListSelection.Key(key(flow)))
|
||||
}
|
||||
|
||||
private fun open(flow: CommandFileDto) {
|
||||
if (!flow.editable) return
|
||||
if (!launch("open") { id ->
|
||||
val opened = service<KiloWorkspaceService>().openFile(flow.location)
|
||||
withContext(workflowEdt) {
|
||||
if (!active(id)) return@withContext
|
||||
setBusy(false)
|
||||
if (opened) return@withContext
|
||||
clearProgress()
|
||||
KiloNotifications.error(KiloBundle.message("settings.agentBehavior.workflows.openInEditor.failed"))
|
||||
}
|
||||
}) return
|
||||
showProgress(KiloBundle.message("settings.agentBehavior.workflows.openInEditor.pending"))
|
||||
}
|
||||
|
||||
private fun remove(flow: CommandFileDto) {
|
||||
val result = Messages.showYesNoDialog(
|
||||
KiloBundle.message("settings.agentBehavior.workflows.delete.message", flow.name),
|
||||
KiloBundle.message("settings.agentBehavior.workflows.delete.title"),
|
||||
KiloBundle.message("common.delete"),
|
||||
Messages.getCancelButton(),
|
||||
Messages.getQuestionIcon(),
|
||||
)
|
||||
if (result != Messages.YES) return
|
||||
state.update { copy(deleted = deleted + flow.location, edited = edited - flow.location) }
|
||||
view.update(rows(), selectionIndex())
|
||||
}
|
||||
|
||||
private fun content(flow: CommandFileDto) = draft.edited[flow.location] ?: flow.content
|
||||
|
||||
private companion object {
|
||||
const val EDIT_CELL = "edit"
|
||||
const val OPEN_CELL = "open"
|
||||
const val DELETE_CELL = "delete"
|
||||
const val BUILTIN = "builtin"
|
||||
const val LEGACY_BUILTIN = "<built-in>"
|
||||
val LOG = KiloLog.create(WorkflowsSettingsUi::class.java)
|
||||
|
||||
fun key(flow: CommandFileDto) = if (builtin(flow)) {
|
||||
listOf("builtin", flow.source.orEmpty(), flow.name).joinToString(":")
|
||||
} else {
|
||||
flow.location.ifBlank { flow.name }
|
||||
}
|
||||
fun builtin(flow: CommandFileDto) = flow.builtin || flow.location == BUILTIN || flow.location == LEGACY_BUILTIN
|
||||
}
|
||||
}
|
||||
|
||||
internal interface WorkflowEditDialogHandle {
|
||||
fun showAndGet(): Boolean
|
||||
fun content(): String
|
||||
}
|
||||
|
||||
private data class WorkflowsDraft(
|
||||
val edited: Map<String, String> = emptyMap(),
|
||||
val deleted: Set<String> = emptySet(),
|
||||
)
|
||||
|
||||
private fun workflowsDraft() = WorkflowsDraft()
|
||||
|
||||
private fun saved(base: WorkflowsDraft, draft: WorkflowsDraft): Boolean = base == draft
|
||||
|
||||
internal class WorkflowEditDialog(private val flow: CommandFileDto, private val savable: Boolean) : DialogWrapper(true), WorkflowEditDialogHandle {
|
||||
private val base = initial()
|
||||
private val editor = SettingsContentField(base, workflowFileType(flow.location, base), savable)
|
||||
|
||||
init {
|
||||
title = "/${flow.name}"
|
||||
setOKButtonText(CommonBundle.getOkButtonText())
|
||||
setCancelButtonText(CommonBundle.getCloseButtonText())
|
||||
init()
|
||||
isOKActionEnabled = false
|
||||
editor.document.addDocumentListener(object : DocumentListener {
|
||||
override fun documentChanged(event: DocumentEvent) {
|
||||
isOKActionEnabled = savable && editor.text != base
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent = settingsContentScroll(editor)
|
||||
|
||||
override fun createActions() = if (savable) arrayOf(okAction, cancelAction) else arrayOf(cancelAction)
|
||||
|
||||
override fun content() = editor.text
|
||||
|
||||
private fun initial() = flow.content?.takeIf { it.isNotBlank() }
|
||||
?: flow.description?.takeIf { it.isNotBlank() }
|
||||
?: KiloBundle.message("settings.agentBehavior.workflows.content.empty")
|
||||
}
|
||||
|
||||
internal fun workflowFileType(location: String, content: String? = null): FileType =
|
||||
settingsEditorFileType(location.ifBlank { WORKFLOW_FILE }, content)
|
||||
|
||||
private const val WORKFLOW_FILE = "workflow.md"
|
||||
private const val WORKFLOW_LOAD_TIMEOUT_MS = 10_000L
|
||||
@@ -112,6 +112,13 @@
|
||||
bundle="messages.KiloBundle"
|
||||
key="settings.agentBehavior.skills.displayName"/>
|
||||
|
||||
<applicationConfigurable
|
||||
parentId="ai.kilocode.jetbrains.settings.agentBehavior"
|
||||
id="ai.kilocode.jetbrains.settings.agentBehavior.workflows"
|
||||
instance="ai.kilocode.client.settings.agents.WorkflowsConfigurable"
|
||||
bundle="messages.KiloBundle"
|
||||
key="settings.agentBehavior.workflows.displayName"/>
|
||||
|
||||
<applicationConfigurable
|
||||
parentId="ai.kilocode.jetbrains.settings.agentBehavior"
|
||||
id="ai.kilocode.jetbrains.settings.agentBehavior.rules"
|
||||
|
||||
@@ -655,6 +655,7 @@ settings.agentBehavior.mcp.status.needsAuth=needs auth
|
||||
settings.agentBehavior.mcp.status.needsRegistration=needs registration
|
||||
settings.agentBehavior.mcp.status.disabled=disabled
|
||||
settings.agentBehavior.skills.displayName=Skills
|
||||
settings.agentBehavior.workflows.displayName=Workflows
|
||||
settings.agentBehavior.rules.displayName=Rules
|
||||
settings.rules.files.title=Additional Instruction Files
|
||||
settings.rules.files.description=Paths to additional instruction files that are included in the system prompt
|
||||
@@ -701,6 +702,18 @@ settings.agentBehavior.skills.sources.addUrl.title=Add Skill URL
|
||||
settings.agentBehavior.skills.sources.addUrl.prompt=Enter a skill source URL.
|
||||
settings.agentBehavior.skills.sources.editPath.title=Edit Skill Path
|
||||
settings.agentBehavior.skills.sources.editUrl.title=Edit Skill URL
|
||||
settings.agentBehavior.workflows.search=Filter workflows
|
||||
settings.agentBehavior.workflows.empty=No workflows found.
|
||||
settings.agentBehavior.workflows.content.empty=No workflow content available.
|
||||
settings.agentBehavior.workflows.load.timeout=Workflow loading timed out. Existing workflows were kept; refresh after slow sources recover.
|
||||
settings.agentBehavior.workflows.reload.blocked=Workflows settings saved, but active sessions are present. Reload the core after those sessions finish to apply the new workflows.
|
||||
settings.agentBehavior.workflows.saved.notification=Workflows settings saved
|
||||
settings.agentBehavior.workflows.delete.title=Delete Workflow
|
||||
settings.agentBehavior.workflows.delete.message=Delete workflow {0}? This removes the workflow file and cannot be undone.
|
||||
settings.agentBehavior.workflows.delete.failed=Could not delete the workflow.
|
||||
settings.agentBehavior.workflows.openInEditor=Open in Editor
|
||||
settings.agentBehavior.workflows.openInEditor.pending=The workflow file will open after you close Settings.
|
||||
settings.agentBehavior.workflows.openInEditor.failed=Could not open the workflow file in the editor.
|
||||
settings.providers.loading=Loading providers...
|
||||
settings.providers.connected=Connected providers
|
||||
settings.providers.available=Available providers
|
||||
|
||||
+30
@@ -2,6 +2,7 @@ package ai.kilocode.client.app
|
||||
|
||||
import ai.kilocode.client.testing.FakeAgentBehaviorRpcApi
|
||||
import ai.kilocode.rpc.dto.AgentCreateDto
|
||||
import ai.kilocode.rpc.dto.CommandFileDto
|
||||
import ai.kilocode.rpc.dto.McpStatusDto
|
||||
import ai.kilocode.rpc.dto.SkillDto
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
@@ -98,6 +99,35 @@ class KiloAgentBehaviorServiceTest : BasePlatformTestCase() {
|
||||
assertEquals("# Saved", rpc.skills.single().content)
|
||||
}
|
||||
|
||||
fun `test loadCommandFiles propagates rpc failure`() = runBlocking {
|
||||
rpc.commandFilesError = RuntimeException("boom")
|
||||
|
||||
assertFailsWith<RuntimeException> {
|
||||
withContext(Dispatchers.Default) { service.loadCommandFiles("/test") }
|
||||
}
|
||||
}
|
||||
|
||||
fun `test refreshCommandFiles returns previous rows on rpc failure`() = runBlocking {
|
||||
val fallback = listOf(CommandFileDto("plan", location = "/test/.kilo/workflows/plan.md"))
|
||||
rpc.commandFilesError = RuntimeException("boom")
|
||||
|
||||
val items = withContext(Dispatchers.Default) { service.refreshCommandFiles("/test", fallback) }
|
||||
|
||||
assertEquals(fallback, items)
|
||||
}
|
||||
|
||||
fun `test saveCommands forwards all edits`() = runBlocking {
|
||||
rpc.commandFiles = listOf(CommandFileDto("plan", location = "/test/.kilo/workflows/plan.md"))
|
||||
|
||||
val ok = withContext(Dispatchers.Default) {
|
||||
service.saveCommands("/test", mapOf("/test/.kilo/workflows/plan.md" to "# Saved"))
|
||||
}
|
||||
|
||||
assertTrue(ok)
|
||||
assertEquals(listOf(Triple("/test", "/test/.kilo/workflows/plan.md", "# Saved")), rpc.commandSaves)
|
||||
assertEquals("# Saved", rpc.commandFiles.single().content)
|
||||
}
|
||||
|
||||
fun `test mcpStatus forwards directory`() = runBlocking {
|
||||
rpc.mcps = listOf(McpStatusDto("filesystem", "connected"))
|
||||
|
||||
|
||||
+2
-1
@@ -21,6 +21,7 @@ class AgentBehaviorConfigurableTest : BasePlatformTestCase() {
|
||||
assertEquals("ai.kilocode.jetbrains.settings.agentBehavior.agents", AgentsConfigurable.ID)
|
||||
assertEquals("ai.kilocode.jetbrains.settings.agentBehavior.mcp", McpConfigurable.ID)
|
||||
assertEquals("ai.kilocode.jetbrains.settings.agentBehavior.skills", SkillsConfigurable.ID)
|
||||
assertEquals("ai.kilocode.jetbrains.settings.agentBehavior.workflows", WorkflowsConfigurable.ID)
|
||||
assertEquals("ai.kilocode.jetbrains.settings.agentBehavior.rules", RulesConfigurable.ID)
|
||||
}
|
||||
|
||||
@@ -30,7 +31,7 @@ class AgentBehaviorConfigurableTest : BasePlatformTestCase() {
|
||||
edt {
|
||||
val panel = cfg.createComponent()
|
||||
val labels = links(panel as Container).map { it.text }
|
||||
assertEquals(listOf("Agents", "MCP Servers", "Skills", "Rules"), labels)
|
||||
assertEquals(listOf("Agents", "MCP Servers", "Skills", "Workflows", "Rules"), labels)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+465
@@ -0,0 +1,465 @@
|
||||
package ai.kilocode.client.settings.agents
|
||||
|
||||
import ai.kilocode.client.app.KiloAgentBehaviorService
|
||||
import ai.kilocode.client.app.KiloWorkspaceService
|
||||
import ai.kilocode.client.testing.FakeAgentBehaviorRpcApi
|
||||
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
|
||||
import ai.kilocode.client.testing.fire
|
||||
import ai.kilocode.client.ui.list.ActiveListItem
|
||||
import ai.kilocode.client.ui.list.activeListCellBounds
|
||||
import ai.kilocode.client.util.edtWait
|
||||
import ai.kilocode.rpc.dto.CommandFileDto
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.fileTypes.FileTypeManager
|
||||
import com.intellij.openapi.fileTypes.PlainTextFileType
|
||||
import com.intellij.openapi.fileTypes.UnknownFileType
|
||||
import com.intellij.openapi.ui.DialogWrapper
|
||||
import com.intellij.openapi.ui.Messages
|
||||
import com.intellij.openapi.ui.TestDialog
|
||||
import com.intellij.openapi.ui.TestDialogManager
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.testFramework.replaceService
|
||||
import com.intellij.ui.SimpleColoredComponent
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBList
|
||||
import com.intellij.ui.components.JBScrollPane
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import java.awt.Container
|
||||
import java.awt.Dimension
|
||||
import java.awt.Point
|
||||
import java.awt.event.InputEvent
|
||||
import java.awt.event.MouseEvent
|
||||
import javax.swing.JTextField
|
||||
import javax.swing.ScrollPaneConstants
|
||||
import javax.swing.Scrollable
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class WorkflowsSettingsUiTest : BasePlatformTestCase() {
|
||||
private var scope: CoroutineScope? = null
|
||||
private var ui: WorkflowsSettingsUi? = null
|
||||
private lateinit var agentRpc: FakeAgentBehaviorRpcApi
|
||||
private lateinit var workspaceRpc: FakeWorkspaceRpcApi
|
||||
private var shown = 0
|
||||
|
||||
override fun tearDown() {
|
||||
try {
|
||||
TestDialogManager.setTestDialog(TestDialog.DEFAULT)
|
||||
ui?.let { panel -> edt { panel.dispose(); true } }
|
||||
ui = null
|
||||
scope?.cancel()
|
||||
scope = null
|
||||
} finally {
|
||||
super.tearDown()
|
||||
}
|
||||
}
|
||||
|
||||
fun `test loads workflows with location note and builtins have no actions`() {
|
||||
val panel = panel()
|
||||
|
||||
flushUntil { rows(panel).size == 3 }
|
||||
|
||||
edt {
|
||||
val rows = rows(panel)
|
||||
val custom = rows.single { it.key == CUSTOM }
|
||||
assertEquals("/plan", custom.title)
|
||||
assertEquals(CUSTOM, custom.note)
|
||||
assertEquals("Plan work", custom.description)
|
||||
assertEquals("edit", custom.doubleClick)
|
||||
assertEquals(listOf("open", "edit", "delete"), custom.cells.map { it.id })
|
||||
assertTrue(custom.cells.single { it.id == "open" }.primary)
|
||||
assertFalse(custom.cells.single { it.id == "edit" }.primary)
|
||||
assertEquals("Edit", custom.cells.single { it.id == "edit" }.label)
|
||||
assertTrue(custom.cells.single { it.id == "delete" }.iconOnly)
|
||||
val builtin = rows.single { it.key == "builtin::init" }
|
||||
assertEquals("/init", builtin.title)
|
||||
assertNull(builtin.note)
|
||||
assertEquals(listOf("built-in"), builtin.badges.map { it.text })
|
||||
assertEquals(listOf("edit"), builtin.cells.map { it.id })
|
||||
assertEquals("Open", builtin.cells.single().label)
|
||||
val remote = rows.single { it.key == REMOTE }
|
||||
assertEquals(listOf("edit"), remote.cells.map { it.id })
|
||||
assertEquals("Open", remote.cells.single().label)
|
||||
assertEquals(listOf(DIR), agentRpc.commandCalls)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun `test workflows list is vertically scrolled without horizontal scrollbar`() {
|
||||
val panel = panel()
|
||||
flushUntil { rows(panel).size == 3 }
|
||||
|
||||
edt {
|
||||
val pane = scrollFor(panel, workflowsList(panel))
|
||||
val view = pane.viewport.view
|
||||
|
||||
assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, pane.horizontalScrollBarPolicy)
|
||||
assertTrue((view as Scrollable).getScrollableTracksViewportWidth())
|
||||
assertFalse(view.getScrollableTracksViewportHeight())
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun `test renderer puts location on first line and description on preview line`() {
|
||||
val panel = panel()
|
||||
flushUntil { rows(panel).size == 3 }
|
||||
|
||||
edt {
|
||||
val list = workflowsList(panel)
|
||||
val row = rows(panel).single { it.key == CUSTOM }
|
||||
val idx = rows(panel).indexOf(row)
|
||||
val comp = list.cellRenderer.getListCellRendererComponent(list, row, idx, true, true)
|
||||
comp.setSize(520, list.fixedCellHeight)
|
||||
layout(comp)
|
||||
val title = components(comp).filterIsInstance<SimpleColoredComponent>().single()
|
||||
val labels = components(comp).filterIsInstance<JBLabel>().filter { it.isVisible }.map { it.text }
|
||||
|
||||
assertEquals("/plan $CUSTOM", title.toString())
|
||||
assertTrue(labels.contains("Plan work"))
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun `test double click stages workflow content until apply`() {
|
||||
val panel = panel(edit = { _, _ -> FakeWorkflowDialog("# Saved") })
|
||||
flushUntil { rows(panel).size == 3 }
|
||||
|
||||
doubleClick(workflowsList(panel), panel, CUSTOM)
|
||||
|
||||
assertTrue(edt { panel.modified() })
|
||||
assertTrue(agentRpc.commandSaves.isEmpty())
|
||||
edt { panel.applyDraft(); true }
|
||||
flushUntil { agentRpc.commandSaves.size == 1 }
|
||||
assertEquals(Triple(DIR, CUSTOM, "# Saved"), agentRpc.commandSaves.single())
|
||||
}
|
||||
|
||||
fun `test reopening staged workflow edit shows draft content before apply`() {
|
||||
val seen = mutableListOf<String?>()
|
||||
val panel = panel(edit = { flow, _ ->
|
||||
seen += flow.content
|
||||
FakeWorkflowDialog(if (seen.size == 1) "# Draft" else "# Draft 2")
|
||||
})
|
||||
flushUntil { rows(panel).size == 3 }
|
||||
|
||||
doubleClick(workflowsList(panel), panel, CUSTOM)
|
||||
doubleClick(workflowsList(panel), panel, CUSTOM)
|
||||
|
||||
assertEquals(listOf("# Plan\nUse steps", "# Draft"), seen)
|
||||
assertTrue(agentRpc.commandSaves.isEmpty())
|
||||
}
|
||||
|
||||
fun `test open in editor action opens workflow file`() {
|
||||
val panel = panel()
|
||||
flushUntil { rows(panel).size == 3 }
|
||||
|
||||
click(workflowsList(panel), panel, CUSTOM, "open")
|
||||
|
||||
assertEquals("The workflow file will open after you close Settings.", edt { progressText(panel) })
|
||||
flushUntil { workspaceRpc.openedFiles.size == 1 }
|
||||
assertEquals(FakeWorkspaceRpcApi.Opened(CUSTOM, null, null), workspaceRpc.openedFiles.single())
|
||||
}
|
||||
|
||||
fun `test read only workflows open without staging edits or editor file open`() {
|
||||
shown = 0
|
||||
val panel = panel(edit = { _, savable ->
|
||||
assertFalse(savable)
|
||||
FakeWorkflowDialog("# Ignored") { shown += 1 }
|
||||
})
|
||||
flushUntil { rows(panel).size == 3 }
|
||||
|
||||
click(workflowsList(panel), panel, REMOTE, "edit")
|
||||
|
||||
assertEquals(1, shown)
|
||||
assertFalse(edt { panel.modified() })
|
||||
assertTrue(agentRpc.commandSaves.isEmpty())
|
||||
assertTrue(workspaceRpc.openedFiles.isEmpty())
|
||||
}
|
||||
|
||||
fun `test workflow edit dialog shows content with fallback`() {
|
||||
edt {
|
||||
val content = WorkflowEditDialog(CommandFileDto("plan", "desc", location = CUSTOM, content = "# Plan\nUse steps"), true)
|
||||
val fallback = WorkflowEditDialog(CommandFileDto("plan", "desc", location = CUSTOM), true)
|
||||
val readonly = WorkflowEditDialog(CommandFileDto("init", "desc", location = "builtin", content = "Built in content"), false)
|
||||
try {
|
||||
assertEquals("# Plan\nUse steps", content.content())
|
||||
assertEquals("desc", fallback.content())
|
||||
assertEquals("Built in content", readonly.content())
|
||||
assertEquals("OK", content.okText())
|
||||
} finally {
|
||||
content.close(DialogWrapper.CANCEL_EXIT_CODE)
|
||||
fallback.close(DialogWrapper.CANCEL_EXIT_CODE)
|
||||
readonly.close(DialogWrapper.CANCEL_EXIT_CODE)
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun `test delete action stages workflow removal until apply`() {
|
||||
val panel = panel()
|
||||
flushUntil { rows(panel).size == 3 }
|
||||
TestDialogManager.setTestDialog(TestDialog.YES)
|
||||
|
||||
click(workflowsList(panel), panel, CUSTOM, "delete")
|
||||
|
||||
assertTrue(edt { rows(panel).none { it.key == CUSTOM } })
|
||||
assertTrue(agentRpc.commandRemovals.isEmpty())
|
||||
edt { panel.applyDraft(); true }
|
||||
flushUntil { agentRpc.commandRemovals.size == 1 }
|
||||
assertEquals(listOf(DIR to CUSTOM), agentRpc.commandRemovals)
|
||||
}
|
||||
|
||||
fun `test delete action requires confirmation`() {
|
||||
val panel = panel()
|
||||
flushUntil { rows(panel).size == 3 }
|
||||
TestDialogManager.setTestDialog { Messages.NO }
|
||||
|
||||
click(workflowsList(panel), panel, CUSTOM, "delete")
|
||||
|
||||
edt { UIUtil.dispatchAllInvocationEvents(); true }
|
||||
assertTrue(agentRpc.commandRemovals.isEmpty())
|
||||
assertTrue(edt { rows(panel).any { it.key == CUSTOM } })
|
||||
}
|
||||
|
||||
fun `test blocked reload completes apply with warning`() {
|
||||
val panel = panel(edit = { _, _ -> FakeWorkflowDialog("# Saved") })
|
||||
agentRpc.reloadCommandResult = false
|
||||
flushUntil { rows(panel).size == 3 }
|
||||
|
||||
doubleClick(workflowsList(panel), panel, CUSTOM)
|
||||
edt { panel.applyDraft(); true }
|
||||
|
||||
flushUntil { agentRpc.commandSaves.size == 1 && !edt { panel.modified() } }
|
||||
assertEquals(listOf(DIR), agentRpc.commandReloads)
|
||||
assertEquals("Workflows settings saved, but active sessions are present. Reload the core after those sessions finish to apply the new workflows.", edt { progressText(panel) })
|
||||
}
|
||||
|
||||
fun `test post apply workflows refresh failure keeps saved rows`() {
|
||||
val panel = panel(edit = { _, _ -> FakeWorkflowDialog("# Saved") })
|
||||
flushUntil { rows(panel).size == 3 }
|
||||
|
||||
doubleClick(workflowsList(panel), panel, CUSTOM)
|
||||
agentRpc.commandFilesError = RuntimeException("timeout")
|
||||
edt { panel.applyDraft(); true }
|
||||
|
||||
flushUntil { agentRpc.commandSaves.size == 1 && !edt { panel.modified() } }
|
||||
assertEquals(listOf(CUSTOM, "builtin::init", REMOTE), edt { rows(panel).map { it.key } })
|
||||
assertEquals("# Saved", agentRpc.commandFiles.single { it.location == CUSTOM }.content)
|
||||
}
|
||||
|
||||
fun `test fileless workflow keys stay unique and route read only opens`() {
|
||||
val seen = mutableListOf<String?>()
|
||||
val panel = panel(edit = { flow, savable ->
|
||||
assertFalse(savable)
|
||||
seen += flow.content
|
||||
FakeWorkflowDialog("# Ignored")
|
||||
})
|
||||
flushUntil { rows(panel).size == 3 }
|
||||
agentRpc.commandFiles = listOf(
|
||||
CommandFileDto("init", "Built in", builtin = true, location = "builtin", content = "Init content"),
|
||||
CommandFileDto("review", "Built in", builtin = true, location = "builtin", content = "Review content"),
|
||||
)
|
||||
edt { panel.reload(); true }
|
||||
flushUntil { rows(panel).size == 2 }
|
||||
|
||||
click(workflowsList(panel), panel, "builtin::init", "edit")
|
||||
click(workflowsList(panel), panel, "builtin::review", "edit")
|
||||
|
||||
assertEquals(listOf("builtin::init", "builtin::review"), edt { rows(panel).map { it.key } })
|
||||
assertEquals(listOf("Init content", "Review content"), seen)
|
||||
}
|
||||
|
||||
fun `test apply while list busy preserves staged workflow edits`() {
|
||||
val panel = panel(edit = { _, _ -> FakeWorkflowDialog("# Saved") })
|
||||
flushUntil { rows(panel).size == 3 }
|
||||
doubleClick(workflowsList(panel), panel, CUSTOM)
|
||||
agentRpc.commandFilesGate = CompletableDeferred()
|
||||
|
||||
edt {
|
||||
panel.reload()
|
||||
panel.applyDraft()
|
||||
true
|
||||
}
|
||||
|
||||
assertTrue(edt { panel.modified() })
|
||||
assertTrue(agentRpc.commandSaves.isEmpty())
|
||||
agentRpc.commandFilesGate?.complete(Unit)
|
||||
}
|
||||
|
||||
fun `test failed open in editor clears pending banner`() {
|
||||
val panel = panel()
|
||||
workspaceRpc.openResult = false
|
||||
flushUntil { rows(panel).size == 3 }
|
||||
|
||||
click(workflowsList(panel), panel, CUSTOM, "open")
|
||||
|
||||
flushUntil { workspaceRpc.openedFiles.size == 1 && edt { !panel.progress.isVisible && progressText(panel).isBlank() } }
|
||||
}
|
||||
|
||||
fun `test search filters workflows by name`() {
|
||||
val panel = panel()
|
||||
flushUntil { rows(panel).size == 3 }
|
||||
|
||||
edt {
|
||||
components(panel).filterIsInstance<JTextField>().single().text = "init"
|
||||
UIUtil.dispatchAllInvocationEvents()
|
||||
true
|
||||
}
|
||||
|
||||
flushUntil { rows(panel).map { it.key } == listOf("builtin::init") }
|
||||
}
|
||||
|
||||
fun `test workflows reload failure keeps existing rows`() {
|
||||
val panel = panel()
|
||||
flushUntil { rows(panel).size == 3 }
|
||||
agentRpc.commandFilesError = RuntimeException("timeout")
|
||||
|
||||
edt { panel.reload(); true }
|
||||
flushUntil { edt { workflowsList(panel).isEnabled } }
|
||||
|
||||
assertEquals(listOf(CUSTOM, "builtin::init", REMOTE), edt { rows(panel).map { it.key } })
|
||||
}
|
||||
|
||||
fun `test workflow editor file type follows location extension`() {
|
||||
assertNotSame(UnknownFileType.INSTANCE, workflowFileType("/tmp/workflows/plan.md"))
|
||||
assertEquals(
|
||||
FileTypeManager.getInstance().getFileTypeByFileName("index.html"),
|
||||
workflowFileType("/tmp/workflows/index.html"),
|
||||
)
|
||||
assertEquals(PlainTextFileType.INSTANCE, workflowFileType("/tmp/workflows/index.unknown"))
|
||||
}
|
||||
|
||||
private fun panel(
|
||||
edit: (CommandFileDto, Boolean) -> WorkflowEditDialogHandle = { _, _ -> FakeWorkflowDialog("# Plan\nUse steps") },
|
||||
): WorkflowsSettingsUi {
|
||||
install()
|
||||
val panel = edt { WorkflowsSettingsUi(scope!!, DIR, edit) }
|
||||
ui = panel
|
||||
edt { panel.reload(); true }
|
||||
return panel
|
||||
}
|
||||
|
||||
private fun install() {
|
||||
val cs = CoroutineScope(SupervisorJob())
|
||||
scope = cs
|
||||
workspaceRpc = FakeWorkspaceRpcApi()
|
||||
agentRpc = FakeAgentBehaviorRpcApi().apply {
|
||||
commandFiles = listOf(
|
||||
CommandFileDto("plan", "Plan work", location = CUSTOM, editable = true, content = "# Plan\nUse steps"),
|
||||
CommandFileDto("init", "Built in", builtin = true, location = "builtin", content = "Built in content"),
|
||||
CommandFileDto("remote", "Remote workflow", location = REMOTE, content = "# Remote workflow"),
|
||||
)
|
||||
}
|
||||
ApplicationManager.getApplication().replaceService(KiloAgentBehaviorService::class.java, KiloAgentBehaviorService(cs, agentRpc), testRootDisposable)
|
||||
ApplicationManager.getApplication().replaceService(KiloWorkspaceService::class.java, KiloWorkspaceService(cs, workspaceRpc), testRootDisposable)
|
||||
}
|
||||
|
||||
private fun click(list: JBList<ActiveListItem>, panel: WorkflowsSettingsUi, key: String, id: String) {
|
||||
edt {
|
||||
list.size = Dimension(520, 320)
|
||||
list.doLayout()
|
||||
val idx = rows(panel).indexOfFirst { it.key == key }
|
||||
list.selectedIndex = idx
|
||||
val area = activeListCellBounds(list, idx, selected = true).getValue(id)
|
||||
click(list, center(area))
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun doubleClick(list: JBList<ActiveListItem>, panel: WorkflowsSettingsUi, key: String) {
|
||||
edt {
|
||||
list.size = Dimension(520, 320)
|
||||
list.doLayout()
|
||||
val idx = rows(panel).indexOfFirst { it.key == key }
|
||||
list.selectedIndex = idx
|
||||
val area = list.getCellBounds(idx, idx)
|
||||
fire(list, mouse(list, MouseEvent.MOUSE_CLICKED, center(area), count = 2))
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun rows(panel: WorkflowsSettingsUi): List<ActiveListItem> = items(workflowsList(panel))
|
||||
|
||||
private fun items(list: JBList<ActiveListItem>): List<ActiveListItem> {
|
||||
val model = list.model
|
||||
return (0 until model.size).map { model.getElementAt(it) }
|
||||
}
|
||||
|
||||
private fun workflowsList(panel: WorkflowsSettingsUi) = components(panel).filterIsInstance<JBList<ActiveListItem>>().first()
|
||||
|
||||
private fun scrollFor(panel: WorkflowsSettingsUi, list: JBList<ActiveListItem>) = components(panel)
|
||||
.filterIsInstance<JBScrollPane>()
|
||||
.single { pane -> pane.viewport.view === list.parent }
|
||||
|
||||
private fun progressText(panel: WorkflowsSettingsUi) = components(panel.progress).filterIsInstance<JBLabel>().single().text
|
||||
|
||||
private fun WorkflowEditDialog.okText(): String {
|
||||
val method = DialogWrapper::class.java.getDeclaredMethod("getOKAction")
|
||||
method.isAccessible = true
|
||||
return (method.invoke(this) as javax.swing.Action).getValue(javax.swing.Action.NAME) as String
|
||||
}
|
||||
|
||||
private fun components(root: java.awt.Component): List<java.awt.Component> {
|
||||
val out = mutableListOf<java.awt.Component>()
|
||||
fun visit(item: java.awt.Component) {
|
||||
out += item
|
||||
if (item is Container) item.components.forEach { visit(it) }
|
||||
}
|
||||
visit(root)
|
||||
return out
|
||||
}
|
||||
|
||||
private fun layout(root: java.awt.Component) {
|
||||
root.doLayout()
|
||||
if (root is Container) root.components.filterIsInstance<Container>().forEach { layout(it) }
|
||||
UIUtil.dispatchAllInvocationEvents()
|
||||
}
|
||||
|
||||
private fun center(rect: java.awt.Rectangle) = Point(rect.x + rect.width / 2, rect.y + rect.height / 2)
|
||||
|
||||
private fun click(list: JBList<ActiveListItem>, point: Point) {
|
||||
fire(list, mouse(list, MouseEvent.MOUSE_PRESSED, point))
|
||||
fire(list, mouse(list, MouseEvent.MOUSE_RELEASED, point))
|
||||
}
|
||||
|
||||
private fun mouse(list: JBList<ActiveListItem>, id: Int, point: Point, count: Int = 1) = MouseEvent(
|
||||
list,
|
||||
id,
|
||||
System.currentTimeMillis(),
|
||||
if (id == MouseEvent.MOUSE_PRESSED) InputEvent.BUTTON1_DOWN_MASK else 0,
|
||||
point.x,
|
||||
point.y,
|
||||
count,
|
||||
false,
|
||||
MouseEvent.BUTTON1,
|
||||
)
|
||||
|
||||
private fun <T> edt(block: () -> T): T = edtWait(block)
|
||||
|
||||
private fun flushUntil(done: () -> Boolean) = runBlocking {
|
||||
repeat(300) {
|
||||
delay(10)
|
||||
edt { UIUtil.dispatchAllInvocationEvents(); true }
|
||||
if (done()) return@runBlocking
|
||||
}
|
||||
edt { UIUtil.dispatchAllInvocationEvents(); true }
|
||||
assertTrue(done())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DIR = "/test"
|
||||
const val CUSTOM = "/home/test/.kilo/workflows/plan.md"
|
||||
const val REMOTE = "/home/test/.cache/kilo/commands/remote.md"
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeWorkflowDialog(private val text: String, private val show: () -> Unit = {}) : WorkflowEditDialogHandle {
|
||||
override fun showAndGet(): Boolean {
|
||||
show()
|
||||
return true
|
||||
}
|
||||
override fun content() = text
|
||||
}
|
||||
+3
@@ -9,6 +9,7 @@ import ai.kilocode.rpc.dto.McpConfigDto
|
||||
import ai.kilocode.rpc.dto.McpServerConfigDto
|
||||
import ai.kilocode.rpc.dto.McpStatusDto
|
||||
import ai.kilocode.rpc.dto.SkillDto
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
|
||||
class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi {
|
||||
var agents = emptyList<AgentDetailDto>()
|
||||
@@ -39,6 +40,7 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi {
|
||||
var afterMcpConnect: (suspend (String, String) -> Unit)? = null
|
||||
var createError: Exception? = null
|
||||
var skillsError: Exception? = null
|
||||
var commandFilesGate: CompletableDeferred<Unit>? = null
|
||||
var commandFilesError: Exception? = null
|
||||
var removeError: Exception? = null
|
||||
var removeSkillError: Exception? = null
|
||||
@@ -135,6 +137,7 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi {
|
||||
|
||||
override suspend fun commandFiles(directory: String): List<CommandFileDto> {
|
||||
assertNotEdt("agentBehavior.commandFiles")
|
||||
commandFilesGate?.await()
|
||||
commandFilesError?.let { throw it }
|
||||
commandCalls.add(directory)
|
||||
return commandFiles
|
||||
|
||||
Reference in New Issue
Block a user