mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 05:52:35 +08:00
feat(jetbrains): support plan follow-up implementation
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Support starting implementation from completed planning sessions in JetBrains.
|
||||
+1
@@ -48,6 +48,7 @@ class KiloBackendChatManager(
|
||||
"message.part.removed",
|
||||
"session.turn.open",
|
||||
"session.turn.close",
|
||||
"session.created",
|
||||
"session.error",
|
||||
"session.status",
|
||||
"session.updated",
|
||||
|
||||
+33
-13
@@ -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> {
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+72
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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) },
|
||||
)
|
||||
|
||||
+61
-5
@@ -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 {
|
||||
|
||||
+6
@@ -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,
|
||||
)
|
||||
|
||||
+89
@@ -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()
|
||||
?: ""
|
||||
}
|
||||
+7
-1
@@ -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
|
||||
|
||||
+5
-2
@@ -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
|
||||
|
||||
+124
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+4
-1
@@ -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,
|
||||
)
|
||||
|
||||
+1
-1
@@ -454,7 +454,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
|
||||
private fun panelWithPrompts(): SessionMessageListPanel {
|
||||
val q = QuestionView(
|
||||
project = project,
|
||||
reply = { _, _ -> },
|
||||
reply = { _, _, _ -> },
|
||||
reject = { _ -> },
|
||||
)
|
||||
val p = PermissionView(
|
||||
|
||||
+38
@@ -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."
|
||||
}
|
||||
}
|
||||
+34
-2
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user