feat(jetbrains): support plan follow-up implementation

This commit is contained in:
kirillk
2026-05-24 18:31:41 -04:00
parent 59bf44712c
commit 6e633692a2
22 changed files with 549 additions and 30 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Support starting implementation from completed planning sessions in JetBrains.
@@ -48,6 +48,7 @@ class KiloBackendChatManager(
"message.part.removed",
"session.turn.open",
"session.turn.close",
"session.created",
"session.error",
"session.status",
"session.updated",
@@ -132,6 +132,13 @@ object KiloCliDataParser {
ChatEventDto.TurnClose(sid, reason)
}
"session.created" -> {
val info = props["info"]?.jsonObject ?: return null
val dto = parseSessionObject(info)
val sid = props.str("sessionID") ?: dto.id.takeIf { it.isNotBlank() } ?: return null
ChatEventDto.SessionCreated(sid, dto)
}
"session.error" -> {
val sid = props.str("sessionID")
val err = props["error"]?.jsonObject?.let { parseError(it) }
@@ -540,20 +547,28 @@ object KiloCliDataParser {
val sid = obj.str("sessionID") ?: return null
val questions = obj["questions"]?.jsonArray?.map { q ->
val qo = q.jsonObject
val options = qo["options"]?.jsonArray?.map { o ->
val oo = o.jsonObject
QuestionOptionDto(oo.str("label") ?: "", oo.str("description") ?: "")
val options = qo["options"]?.jsonArray?.map { o ->
val oo = o.jsonObject
QuestionOptionDto(
label = oo.str("label") ?: "",
description = oo.str("description") ?: "",
labelKey = oo.str("labelKey"),
descriptionKey = oo.str("descriptionKey"),
mode = oo.str("mode"),
)
} ?: emptyList()
QuestionInfoDto(
question = qo.str("question") ?: "",
header = qo.str("header") ?: "",
options = options,
multiple = qo.flag("multiple", false),
custom = qo.flag("custom", true),
questionKey = qo.str("questionKey"),
headerKey = qo.str("headerKey"),
)
} ?: emptyList()
QuestionInfoDto(
question = qo.str("question") ?: "",
header = qo.str("header") ?: "",
options = options,
multiple = qo.str("multiple") == "true",
custom = qo.str("custom") != "false",
)
} ?: emptyList()
val ref = toolRef(obj)
return QuestionRequestDto(id, sid, questions, ref)
val ref = toolRef(obj)
return QuestionRequestDto(id, sid, questions, ref, blocking = obj.flag("blocking", false))
}
internal fun parseModelFavorites(raw: JsonElement?): List<ModelSelectionDto> {
@@ -869,6 +884,11 @@ private fun JsonObject.long(key: String): Long? =
private fun JsonObject?.bool(key: String): Boolean =
this?.get(key)?.jsonPrimitive?.booleanOrNull ?: false
private fun JsonObject.flag(key: String, default: Boolean): Boolean {
val prim = this[key]?.jsonPrimitive ?: return default
return prim.booleanOrNull ?: prim.contentOrNull?.toBooleanStrictOrNull() ?: default
}
private fun Long.safeInt() = coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()).toInt()
private fun JsonObject?.map(key: String): Map<String, String> {
@@ -116,6 +116,7 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi {
is ChatEventDto.PartRemoved -> event.sessionID
is ChatEventDto.TurnOpen -> event.sessionID
is ChatEventDto.TurnClose -> event.sessionID
is ChatEventDto.SessionCreated -> event.sessionID
is ChatEventDto.Error -> event.sessionID
is ChatEventDto.MessageRemoved -> event.sessionID
is ChatEventDto.PermissionAsked -> event.sessionID
@@ -130,7 +131,7 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi {
is ChatEventDto.SessionDiffChanged -> event.sessionID
is ChatEventDto.TodoUpdated -> event.sessionID
}
val passes = sid == null || sid == id
val passes = event is ChatEventDto.SessionCreated || sid == null || sid == id
if (passes) LOG.debug { "${ChatLogSummary.sid(id)} pass=true ${ChatLogSummary.eventBody(event)}" }
else LOG.debug { "${ChatLogSummary.sid(id)} pass=false srcSid=$sid ${ChatLogSummary.eventBody(event)}" }
passes
@@ -415,6 +415,30 @@ class KiloCliDataParserTest {
assertEquals(2, result.session.summary?.files)
}
@Test
fun `parseChatEvent - session created`() {
val data = globalEvent("""
"type": "session.created",
"properties": {
"sessionID": "ses_new",
"info": {
"id": "ses_new",
"projectID": "proj_1",
"directory": "/test",
"title": "Implementation",
"version": "1",
"time": { "created": 1.0, "updated": 2.0 }
}
}
""")
val result = KiloCliDataParser.parseChatEvent("session.created", data)
assertNotNull(result)
assertTrue(result is ChatEventDto.SessionCreated)
assertEquals("ses_new", result.sessionID)
assertEquals("/test", result.info.directory)
}
@Test
fun `parseChatEvent - session diff`() {
val data = globalEvent("""
@@ -597,6 +621,48 @@ class KiloCliDataParserTest {
assertEquals("A", result.request.questions[0].options[0].label)
}
@Test
fun `parseChatEvent - plan follow-up question preserves fields`() {
val data = globalEvent("""
"type": "question.asked",
"properties": {
"id": "q_plan",
"sessionID": "ses_1",
"blocking": true,
"questions": [{
"question": "Ready to implement?",
"questionKey": "plan.followup.question",
"header": "Implement",
"headerKey": "plan.followup.header",
"multiple": false,
"custom": true,
"options": [{
"label": "Continue here",
"labelKey": "plan.followup.answer.continue",
"description": "Implement the plan in this session",
"descriptionKey": "plan.followup.answer.continue.description",
"mode": "code"
}]
}],
"tool": null
}
""")
val result = KiloCliDataParser.parseChatEvent("question.asked", data)
assertNotNull(result)
assertTrue(result is ChatEventDto.QuestionAsked)
assertEquals(true, result.request.blocking)
val item = result.request.questions.single()
assertEquals("plan.followup.question", item.questionKey)
assertEquals("plan.followup.header", item.headerKey)
assertEquals(false, item.multiple)
assertEquals(true, item.custom)
val opt = item.options.single()
assertEquals("plan.followup.answer.continue", opt.labelKey)
assertEquals("plan.followup.answer.continue.description", opt.descriptionKey)
assertEquals("code", opt.mode)
}
@Test
fun `parseChatEvent - question replied`() {
val data = globalEvent("""
@@ -732,11 +798,16 @@ class KiloCliDataParserTest {
@Test
fun `parseQuestionRequests - parses list`() {
val raw = """[
{"id": "q1", "sessionID": "s1", "questions": [{"question": "pick", "header": "h", "options": []}]}
{"id": "q1", "sessionID": "s1", "blocking": true, "questions": [{"question": "pick", "questionKey": "q.key", "header": "h", "headerKey": "h.key", "multiple": true, "custom": false, "options": [{"label": "A", "description": "B", "mode": "code"}]}]}
]"""
val result = KiloCliDataParser.parseQuestionRequests(raw)
assertEquals(1, result.size)
assertEquals("q1", result[0].id)
assertEquals(true, result[0].blocking)
assertEquals("q.key", result[0].questions[0].questionKey)
assertEquals(true, result[0].questions[0].multiple)
assertEquals(false, result[0].questions[0].custom)
assertEquals("code", result[0].questions[0].options[0].mode)
}
}
@@ -177,7 +177,7 @@ class SessionUi(
progressBody = load
question = QuestionView(
project = project,
reply = { id, dto -> controller.replyQuestion(id, dto) },
reply = { id, dto, opts -> controller.replyQuestion(id, dto, opts) },
reject = { id -> controller.rejectQuestion(id) },
scroll = { scroll.followBottom(true) },
)
@@ -50,6 +50,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import java.awt.Component
import java.nio.file.Path
/**
* Session lifecycle orchestrator for a single session.
@@ -82,11 +83,14 @@ class SessionController(
) : Disposable {
private data class OrganizationTarget(val org: String?)
private data class Followup(val dir: String, val time: Long)
companion object {
private val LOG = KiloLog.create(SessionController::class.java)
internal const val RECENT_LIMIT = 5
internal const val DISPLAY_DELAY_MS = 1_000L
private const val FOLLOWUP_TTL_MS = 30_000L
private const val FOLLOWUP_NEW_SESSION = "Start new session"
}
init {
@@ -126,6 +130,7 @@ class SessionController(
private var lastProfile: ProfileDto? = null
private var target: OrganizationTarget? = null
private var loginRetry: PromptDto? = null
private var followup: Followup? = null
val ready: Boolean get() = model.isReady()
internal val blank: Boolean get() = ref == null && model.isEmpty() && !model.showSession
@@ -350,14 +355,23 @@ class SessionController(
updateModel { model.setState(SessionState.AwaitingPermission(perm)) }
}
fun replyQuestion(requestId: String, answers: QuestionReplyDto) {
fun replyQuestion(requestId: String, answers: QuestionReplyDto, options: List<List<String>> = answers.answers) {
assertEdt()
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId answers=${answers.answers.size}" }
val current = model.state
val mode = if (current is SessionState.AwaitingQuestion && current.question.id == requestId) {
selectedMode(current.question, options)
} else null
if (!mode.isNullOrBlank() && mode != model.agent) selectAgent(mode)
followup = if (answers.answers.firstOrNull()?.firstOrNull()?.trim() == FOLLOWUP_NEW_SESSION) {
Followup(directory, System.currentTimeMillis())
} else null
cs.launch {
try {
sessions.replyQuestion(requestId, directory, answers)
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId ok=true" }
} catch (e: Exception) {
edt { followup = null }
LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId answers=${answers.answers.size} dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
}
}
@@ -365,6 +379,7 @@ class SessionController(
fun rejectQuestion(requestId: String) {
assertEdt()
followup = null
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId rejected=true" }
cs.launch {
try {
@@ -737,13 +752,14 @@ class SessionController(
// Other reasons: don't clobber a more specific terminal state (Error,
// AwaitingPermission, AwaitingQuestion, LoginRequired) that arrived just before close.
val current = model.state
val clobberOk = event.reason == "completed"
|| current is SessionState.Busy
val clobberOk = current is SessionState.Busy
|| current is SessionState.Retry
|| current is SessionState.Offline
if (clobberOk) model.setState(SessionState.Idle)
}
is ChatEventDto.SessionCreated -> adoptFollowup(event.info)
is ChatEventDto.Error -> {
partType = null
tool = null
@@ -955,6 +971,29 @@ class SessionController(
}
}
private fun selectedMode(question: Question, options: List<List<String>>): String? {
for ((idx, labels) in options.withIndex()) {
val item = question.items.getOrNull(idx) ?: continue
for (label in labels) {
val mode = item.options.firstOrNull { it.label == label }?.mode
if (!mode.isNullOrBlank()) return mode
}
}
return null
}
private fun adoptFollowup(session: SessionDto) {
assertEdt()
val item = followup ?: return
if (System.currentTimeMillis() - item.time > FOLLOWUP_TTL_MS) {
followup = null
return
}
if (pathKey(item.dir) != pathKey(session.directory)) return
followup = null
open(SessionRef.Local(session))
}
private fun updateModel(block: () -> Unit) {
assertEdt()
if (disposed) return
@@ -1345,6 +1384,7 @@ private fun matchesSession(event: ChatEventDto, id: String): Boolean = when (eve
is ChatEventDto.PartRemoved -> event.sessionID == id
is ChatEventDto.TurnOpen -> event.sessionID == id
is ChatEventDto.TurnClose -> event.sessionID == id
is ChatEventDto.SessionCreated -> true
is ChatEventDto.Error -> event.sessionID == null || event.sessionID == id
is ChatEventDto.MessageRemoved -> event.sessionID == id
is ChatEventDto.PermissionAsked -> event.sessionID == id
@@ -1413,6 +1453,12 @@ private fun parseModel(value: String): Pair<String, String>? {
return value.substring(0, slash) to value.substring(slash + 1)
}
private fun pathKey(value: String): String = runCatching {
Path.of(value).normalize().toString().trimEnd('/', '\\')
}.getOrElse {
value.replace('\\', '/').trimEnd('/')
}
private sealed interface RecentsState {
data object Idle : RecentsState
data class Loading(val id: Any = Any()) : RecentsState
@@ -1506,12 +1552,22 @@ private fun toQuestion(dto: QuestionRequestDto): Question {
QuestionItem(
question = it.question,
header = it.header,
options = it.options.map { opt -> QuestionOption(opt.label, opt.description) },
options = it.options.map { opt ->
QuestionOption(
label = opt.label,
description = opt.description,
labelKey = opt.labelKey,
descriptionKey = opt.descriptionKey,
mode = opt.mode,
)
},
multiple = it.multiple,
custom = it.custom,
questionKey = it.questionKey,
headerKey = it.headerKey,
)
}
return Question(id = dto.id, items = items, tool = ref)
return Question(id = dto.id, items = items, tool = ref, blocking = dto.blocking)
}
private fun String.toDumpText(): String {
@@ -7,6 +7,7 @@ data class Question(
val items: List<QuestionItem>,
val tool: ToolCallRef? = null,
val state: QuestionRequestState = QuestionRequestState.PENDING,
val blocking: Boolean = false,
)
data class QuestionItem(
@@ -15,9 +16,14 @@ data class QuestionItem(
val options: List<QuestionOption>,
val multiple: Boolean,
val custom: Boolean,
val questionKey: String? = null,
val headerKey: String? = null,
)
data class QuestionOption(
val label: String,
val description: String,
val labelKey: String? = null,
val descriptionKey: String? = null,
val mode: String? = null,
)
@@ -0,0 +1,89 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.ui.UiStyle
import com.intellij.icons.AllIcons
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import javax.swing.BoxLayout
import javax.swing.JPanel
class PlanExitView(tool: Tool) : PartView() {
companion object {
fun canRender(tool: Tool): Boolean = tool.name == "plan_exit" && tool.state == ToolExecState.COMPLETED
}
override val contentId: String = tool.id
private var item = tool
private val title = JBLabel(KiloBundle.message("session.part.plan.ready"), AllIcons.Actions.Checked, JBLabel.LEFT)
private val path = JBLabel().apply {
foreground = JBUI.CurrentTheme.Link.Foreground.ENABLED
setCopyable(true)
}
private val body = JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
isOpaque = false
}
private val root = JPanel(BorderLayout()).apply {
isOpaque = true
background = SessionUiStyle.View.surface()
border = SessionUiStyle.View.card()
}
init {
layout = BorderLayout()
isOpaque = false
body.border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING),
)
body.add(title)
body.add(path)
root.add(body, BorderLayout.CENTER)
add(root, BorderLayout.CENTER)
applyStyle(SessionEditorStyle.current())
sync()
}
override fun update(content: Content) {
if (content !is Tool) return
item = content
sync()
}
override fun applyStyle(style: SessionEditorStyle) {
title.font = style.boldEditorFont
path.font = style.smallEditorFont
}
fun labelText(): String = listOf(title.text, path.text).filter { it.isNotBlank() }.joinToString(" ")
private fun sync() {
title.foreground = UiStyle.Colors.fg()
val plan = plan(item)
path.text = plan
path.isVisible = plan.isNotBlank()
}
override fun dumpLabel() = "PlanExitView#$contentId(${labelText()})"
}
private fun plan(tool: Tool): String {
tool.metadata["plan"]?.takeIf { it.isNotBlank() }?.let { return it }
val out = tool.output ?: return ""
return Regex("Plan is ready at (.+?)(?:\\. Ending planning turn\\.|$)")
.find(out)
?.groupValues
?.getOrNull(1)
?.trim()
?: ""
}
@@ -23,7 +23,11 @@ object ViewFactory {
fun create(content: Content): PartView = when (content) {
is Text -> TextView(content)
is Reasoning -> ReasoningView(content)
is Tool -> if (QuestionResultView.canRender(content)) QuestionResultView(content) else ToolView(content)
is Tool -> when {
PlanExitView.canRender(content) -> PlanExitView(content)
QuestionResultView.canRender(content) -> QuestionResultView(content)
else -> ToolView(content)
}
is Compaction -> CompactionView(content)
is StepFinish -> error("step-finish is timeline-only")
is Generic -> GenericView(content)
@@ -36,6 +40,8 @@ object ViewFactory {
*/
fun shouldReplace(view: PartView, content: Content): Boolean {
if (content !is Tool) return false
if (view is PlanExitView) return !PlanExitView.canRender(content)
if (view !is PlanExitView && PlanExitView.canRender(content)) return true
if (view is QuestionResultView) return !QuestionResultView.canRender(content)
if (view is ToolView) return QuestionResultView.canRender(content)
return false
@@ -42,7 +42,7 @@ import com.intellij.openapi.editor.event.DocumentListener
/** Question tool form rendered inside the session transcript. */
class QuestionView(
private val project: Project,
private val reply: (String, QuestionReplyDto) -> Unit,
private val reply: (String, QuestionReplyDto, List<List<String>>) -> Unit,
private val reject: (String) -> Unit,
private val scroll: () -> Unit = {},
) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView {
@@ -255,6 +255,8 @@ class QuestionView(
}
}
private fun optionAnswers(i: Int): List<String> = selections.getOrNull(i)?.toList() ?: emptyList()
private fun addContent(item: QuestionItem, set: MutableSet<String>) {
val opts = optionList(item, set)
opts.alignmentX = Component.LEFT_ALIGNMENT
@@ -709,7 +711,8 @@ class QuestionView(
val id = request ?: return
if ((question?.items?.indices ?: return).any { !isReady(it) }) return
val answers = (question?.items?.indices ?: return).map { effectiveAnswers(it) }
reply(id, QuestionReplyDto(answers))
val opts = (question?.items?.indices ?: return).map { optionAnswers(it) }
reply(id, QuestionReplyDto(answers), opts)
hideView()
}
@@ -81,6 +81,7 @@ session.part.tool.read=Read
session.part.tool.running=Running
session.part.tool.shell=Shell
session.part.tool.truncated=Output truncated in preview. Full output remains in session data.
session.part.plan.ready=Plan is ready
session.error.prompt=Prompt failed
session.error.compact=Session compact failed
@@ -3,6 +3,7 @@ package ai.kilocode.client.session.controller
import ai.kilocode.client.session.model.PermissionFileDiff
import ai.kilocode.client.session.model.PermissionMeta
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.SessionRef
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
@@ -169,6 +170,112 @@ class PromptLifecycleTest : SessionControllerTestBase() {
assertEquals("q1", rpc.questionReplies[0].first)
}
fun `test plan follow-up question enters awaiting state`() {
val (m, _, _) = prompted()
emit(ChatEventDto.QuestionAsked("ses_test", planQuestion("q_plan")))
assertSession(
"""
question#q_plan
tool: <none>
header: Implement
prompt: Ready to implement?
option: Start new session - Implement in a fresh session with a clean context
option: Continue here - Implement the plan in this session
multiple: false
custom: true
[code] [kilo/gpt-5] [awaiting-question]
""",
m,
)
}
fun `test continue here switches mode and sends canonical reply`() {
val (m, _, _) = prompted()
edt { m.model.agent = "plan" }
emit(ChatEventDto.QuestionAsked("ses_test", planQuestion("q_plan")))
edt {
m.replyQuestion(
"q_plan",
QuestionReplyDto(listOf(listOf("Continue here"))),
listOf(listOf("Continue here")),
)
}
flush()
assertEquals("code", m.model.agent)
assertEquals("code", rpc.configs.last().second.agent)
assertQuestionReply("q_plan /test [[Continue here]]", rpc.questionReplies)
}
fun `test custom plan follow-up answer does not switch mode`() {
val (m, _, _) = prompted()
edt { m.model.agent = "plan" }
emit(ChatEventDto.QuestionAsked("ses_test", planQuestion("q_plan")))
edt {
m.replyQuestion(
"q_plan",
QuestionReplyDto(listOf(listOf("Need to adjust scope"))),
listOf(emptyList()),
)
}
flush()
assertEquals("plan", m.model.agent)
assertTrue(rpc.configs.none { it.second.agent == "code" })
assertQuestionReply("q_plan /test [[Need to adjust scope]]", rpc.questionReplies)
}
fun `test start new session adopts matching created session`() {
val opened = mutableListOf<SessionRef>()
val m = controller(open = { opened.add(it) })
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
flush()
edt { m.prompt("go") }
flush()
emit(ChatEventDto.QuestionAsked("ses_test", planQuestion("q_plan")))
edt {
m.replyQuestion(
"q_plan",
QuestionReplyDto(listOf(listOf("Start new session"))),
listOf(listOf("Start new session")),
)
}
emit(ChatEventDto.SessionCreated("ses_new", session("ses_new", dir = "/test")))
flush()
assertEquals("ses_new", (opened.last() as SessionRef.Local).id)
assertEquals(1, rpc.prompts.size)
}
fun `test unrelated session created is ignored`() {
val opened = mutableListOf<SessionRef>()
val m = controller(open = { opened.add(it) })
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
flush()
edt { m.prompt("go") }
flush()
emit(ChatEventDto.QuestionAsked("ses_test", planQuestion("q_plan")))
edt {
m.replyQuestion(
"q_plan",
QuestionReplyDto(listOf(listOf("Start new session"))),
listOf(listOf("Start new session")),
)
}
emit(ChatEventDto.SessionCreated("ses_new", session("ses_new", dir = "/other")))
assertTrue(opened.none { it is SessionRef.Local && it.id == "ses_new" })
}
fun `test rejectQuestion calls RPC`() {
val (m, _, _) = prompted()
emit(ChatEventDto.QuestionAsked("ses_test", question("q1")))
@@ -348,4 +455,21 @@ class PromptLifecycleTest : SessionControllerTestBase() {
),
tool = ToolRefDto("msg1", "call1"),
)
private fun planQuestion(id: String) = QuestionRequestDto(
id = id,
sessionID = "ses_test",
questions = listOf(
QuestionInfoDto(
question = "Ready to implement?",
header = "Implement",
options = listOf(
QuestionOptionDto("Start new session", "Implement in a fresh session with a clean context"),
QuestionOptionDto("Continue here", "Implement the plan in this session", mode = "code"),
),
multiple = false,
custom = true,
),
),
)
}
@@ -128,8 +128,9 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() {
id: String? = null,
flushMs: Long = Long.MAX_VALUE,
displayMs: Long = Long.MAX_VALUE,
open: (SessionRef) -> Unit = {},
): SessionController {
return controller(id, flushMs, true, displayMs = displayMs)
return controller(id, flushMs, true, displayMs = displayMs, open = open)
}
protected fun controller(
@@ -148,6 +149,7 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() {
session: SessionDto? = null,
beforeUpdate: () -> Boolean = { false },
afterUpdate: (Boolean) -> Unit = {},
open: (SessionRef) -> Unit = {},
ref: SessionRef? = if (session != null) SessionRef.Local(session) else SessionRef.from(id),
): SessionController {
val root = Root()
@@ -162,6 +164,7 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() {
flushMs,
condense,
displayMs,
open = open,
beforeUpdate = beforeUpdate,
afterUpdate = afterUpdate,
)
@@ -454,7 +454,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
private fun panelWithPrompts(): SessionMessageListPanel {
val q = QuestionView(
project = project,
reply = { _, _ -> },
reply = { _, _, _ -> },
reject = { _ -> },
)
val p = PermissionView(
@@ -0,0 +1,38 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.model.toolKind
import com.intellij.testFramework.fixtures.BasePlatformTestCase
@Suppress("UnstableApiUsage")
class PlanExitViewTest : BasePlatformTestCase() {
fun `test completed plan exit renders ready text and path`() {
val tool = tool(ToolExecState.COMPLETED).apply {
metadata = mapOf("plan" to ".kilo/plans/x.md")
}
val view = PlanExitView(tool)
assertTrue(view.labelText().contains("Plan is ready"))
assertTrue(view.labelText().contains(".kilo/plans/x.md"))
}
fun `test view factory replaces running tool with plan exit view when completed`() {
val running = tool(ToolExecState.RUNNING)
val existing = ViewFactory.create(running)
assertTrue(existing is ToolView)
val done = tool(ToolExecState.COMPLETED).apply {
metadata = mapOf("plan" to ".kilo/plans/x.md")
}
assertTrue(ViewFactory.shouldReplace(existing, done))
assertTrue(ViewFactory.create(done) is PlanExitView)
}
private fun tool(state: ToolExecState) = Tool("prt_plan", "plan_exit", toolKind("plan_exit")).apply {
this.state = state
output = "Plan is ready at .kilo/plans/x.md. Ending planning turn."
}
}
@@ -25,7 +25,7 @@ import javax.swing.SwingUtilities
@Suppress("UnstableApiUsage")
class QuestionViewTest : BasePlatformTestCase() {
private val replies = mutableListOf<Pair<String, QuestionReplyDto>>()
private val replies = mutableListOf<Triple<String, QuestionReplyDto, List<List<String>>>>()
private val rejects = mutableListOf<String>()
private var scrolls = 0
private lateinit var view: QuestionView
@@ -34,7 +34,7 @@ class QuestionViewTest : BasePlatformTestCase() {
super.setUp()
view = QuestionView(
project = project,
reply = { id, dto -> replies.add(id to dto) },
reply = { id, dto, opts -> replies.add(Triple(id, dto, opts)) },
reject = { id -> rejects.add(id) },
scroll = { scrolls++ },
)
@@ -139,6 +139,7 @@ class QuestionViewTest : BasePlatformTestCase() {
assertEquals(1, replies.size)
assertEquals("req_2", replies.single().first)
assertEquals(listOf(listOf("Minimal")), replies.single().second.answers)
assertEquals(listOf(listOf("Minimal")), replies.single().third)
}
fun `test submit is disabled until question is answered`() {
@@ -594,6 +595,37 @@ class QuestionViewTest : BasePlatformTestCase() {
assertFalse(view.isVisible)
assertEquals(1, replies.size)
assertEquals(listOf(listOf("my custom answer")), replies.single().second.answers)
assertEquals(listOf(emptyList<String>()), replies.single().third)
}
fun `test plan follow-up sends selected option labels separately`() {
view.show(
Question(
id = "q_plan",
items = listOf(
QuestionItem(
question = "Ready to implement?",
header = "Implement",
options = listOf(
QuestionOption("Start new session", "Implement in a fresh session with a clean context"),
QuestionOption("Continue here", "Implement the plan in this session", mode = "code"),
),
multiple = false,
custom = true,
)
),
)
)
assertLabelsContain(view, "Ready to implement?")
assertLabelsContain(view, "Start new session")
assertLabelsContain(view, "Continue here")
assertLabelsContain(view, "Add your own response")
option<JBRadioButton>(view, "Continue here").doClick()
button(view, "Submit").doClick()
assertEquals(listOf(listOf("Continue here")), replies.single().second.answers)
assertEquals(listOf(listOf("Continue here")), replies.single().third)
}
fun `test custom editor grows for wrapped input`() {
@@ -21,6 +21,7 @@ object ChatLogSummary {
is ChatEventDto.PartRemoved -> event.sessionID
is ChatEventDto.TurnOpen -> event.sessionID
is ChatEventDto.TurnClose -> event.sessionID
is ChatEventDto.SessionCreated -> event.sessionID
is ChatEventDto.Error -> event.sessionID
is ChatEventDto.MessageRemoved -> event.sessionID
is ChatEventDto.PermissionAsked -> event.sessionID
@@ -133,6 +134,12 @@ object ChatLogSummary {
"reason=${event.reason}",
)
is ChatEventDto.SessionCreated -> join(
sid(event.sessionID),
"evt=session.created",
"title=${event.info.title.length}",
)
is ChatEventDto.Error -> join(
sid(event.sessionID),
"evt=session.error",
@@ -147,6 +147,13 @@ sealed class ChatEventDto {
val reason: String,
) : ChatEventDto()
@Serializable
@SerialName("session.created")
data class SessionCreated(
val sessionID: String,
val info: SessionDto,
) : ChatEventDto()
@Serializable
@SerialName("session.error")
data class Error(
@@ -291,6 +298,7 @@ data class QuestionRequestDto(
val sessionID: String,
val questions: List<QuestionInfoDto>,
val tool: ToolRefDto? = null,
val blocking: Boolean = false,
)
@Serializable
@@ -300,12 +308,17 @@ data class QuestionInfoDto(
val options: List<QuestionOptionDto> = emptyList(),
val multiple: Boolean = false,
val custom: Boolean = true,
val questionKey: String? = null,
val headerKey: String? = null,
)
@Serializable
data class QuestionOptionDto(
val label: String,
val description: String,
val labelKey: String? = null,
val descriptionKey: String? = null,
val mode: String? = null,
)
@Serializable
@@ -285,7 +285,7 @@ export namespace PlanFollowup {
// main prompt input below the dock already routes typed text as a question
// reply, so "Type your own answer" would be redundant (originally hidden in
// 65566af7f8, flipped back during the v1.4.4 upstream merge).
custom: Flag.KILO_CLIENT === "cli",
custom: Flag.KILO_CLIENT === "cli" || Flag.KILO_CLIENT === "jetbrains",
options: [
{
label: ANSWER_NEW_SESSION,
@@ -28,7 +28,7 @@ export namespace KiloSessionPrompt {
*/
export function shouldAskPlanFollowup(input: { messages: MessageV2.WithParts[]; abort: AbortSignal }) {
if (input.abort.aborted) return false
if (!["cli", "vscode"].includes(Flag.KILO_CLIENT)) return false
if (!["cli", "vscode", "jetbrains"].includes(Flag.KILO_CLIENT)) return false
const idx = input.messages.findLastIndex((m) => m.info.role === "user")
return input.messages
.slice(idx + 1)
@@ -145,6 +145,49 @@ describe("plan_exit detection", () => {
await expect(pending).resolves.toBe("break")
}))
test("JetBrains client enables plan follow-up with custom answer", () =>
withInstance(async () => {
const prev = process.env.KILO_CLIENT
try {
process.env.KILO_CLIENT = "jetbrains"
const seeded = await seed({
text: "Here is the plan",
tools: [
{
tool: "plan_exit",
input: {},
output: "Plan is ready. Ending planning turn.",
},
],
})
expect(SessionPrompt.shouldAskPlanFollowup({ messages: seeded.messages, abort: AbortSignal.any([]) })).toBe(true)
const pending = PlanFollowup.ask({
sessionID: seeded.sessionID,
messages: seeded.messages,
abort: AbortSignal.any([]),
})
const question = await waitQuestion(seeded.sessionID)
expect(question).toBeDefined()
if (!question) return
expect(question.questions[0].question).toBe("Ready to implement?")
expect(question.questions[0].header).toBe("Implement")
expect(question.questions[0].custom).toBe(true)
expect(question.questions[0].options.map((item) => item.label)).toEqual([
PlanFollowup.ANSWER_NEW_SESSION,
PlanFollowup.ANSWER_CONTINUE,
])
expect(question.questions[0].options.find((item) => item.label === PlanFollowup.ANSWER_CONTINUE)?.mode).toBe("code")
await Question.reject(question.id)
await expect(pending).resolves.toBe("break")
} finally {
if (prev === undefined) delete process.env.KILO_CLIENT
else process.env.KILO_CLIENT = prev
}
}))
test("PlanFollowup.ask triggers and continue works with plan_exit", () =>
withInstance(async () => {
const seeded = await seed({