Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/jetbrains-queued-prompts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Allow sending prompts while a session is busy and show queued prompts with a remove action.
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ class KiloBackendChatManager(
"session.status",
"session.updated",
"session.idle",
"session.queue.changed",
"session.compacted",
"session.diff",
"permission.asked",
Expand All @@ -90,14 +91,15 @@ class KiloBackendChatManager(
if (watcher?.isActive == true) return
watcher = cs.launch {
sse.collect { event ->
if (event.type in CHAT_EVENTS) {
val type = if (event.type in CHAT_EVENTS) event.type else KiloCliDataParser.extractEventType(event.data)
if (type in CHAT_EVENTS) {
val events = try {
normalizer.parse(event.type, event.data)
normalizer.parse(type, event.data)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log.warn(
"route=chat-events parse=false type=${event.type} bytes=${event.data.length} ${ChatLogSummary.body(event.data)}",
"route=chat-events parse=false type=$type raw=${event.type} bytes=${event.data.length} ${ChatLogSummary.body(event.data)}",
e,
)
return@collect
Expand All @@ -120,7 +122,7 @@ class KiloBackendChatManager(
_events.emit(parsed)
}
} else {
log.warn("route=chat-events parse=null type=${event.type} bytes=${event.data.length} ${ChatLogSummary.body(event.data)}")
log.warn("route=chat-events parse=null type=$type raw=${event.type} bytes=${event.data.length} ${ChatLogSummary.body(event.data)}")
}
}
}
Expand Down Expand Up @@ -264,6 +266,27 @@ class KiloBackendChatManager(
postCancellable("/session/$id/revert?directory=${encode(dir)}", body, "revert", "${ChatLogSummary.sid(id)} kind=revert")
}

suspend fun deleteMessage(id: String, dir: String, message: String): Boolean {
log.info("${ChatLogSummary.sid(id)} kind=deleteMessage ${ChatLogSummary.dir(dir)} message=$message")
val http = requireClient()
val url = requireBase()
val request = Request.Builder()
.url("$url/session/$id/message/$message?directory=${encode(dir)}")
.delete()
.build()
val call = http.newCall(request)
call.timeout().timeout(REVERT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
return call.await().use { response ->
val raw = response.body?.string().orEmpty().trim()
if (!response.isSuccessful) {
log.warn("deleteMessage failed: HTTP ${response.code}")
raw.takeIf { it.isNotBlank() }?.let { log.debug { "${ChatLogSummary.sid(id)} kind=deleteMessage error=${ChatLogSummary.body(it)}" } }
return@use false
}
raw != "false"
}
}

suspend fun unrevert(id: String, dir: String) {
log.info("${ChatLogSummary.sid(id)} kind=unrevert ${ChatLogSummary.dir(dir)}")
postCancellable("/session/$id/unrevert?directory=${encode(dir)}", "{}", "unrevert", "${ChatLogSummary.sid(id)} kind=unrevert")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,12 @@ object KiloCliDataParser {
ChatEventDto.SessionIdle(sid)
}

"session.queue.changed" -> {
val sid = props.str("sessionID") ?: return null
val queued = props["queued"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
ChatEventDto.SessionQueueChanged(sid, queued)
}

"session.compacted" -> {
val sid = props.str("sessionID") ?: return null
ChatEventDto.SessionCompacted(sid)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ class KiloSessionRpcApiImpl internal constructor(
override suspend fun revert(id: String, directory: String, messageID: String, partID: String?) =
ready { chat.revert(id, sessions.getDirectory(id, directory), messageID, partID) }

override suspend fun deleteMessage(id: String, directory: String, messageID: String): Boolean =
ready { chat.deleteMessage(id, sessions.getDirectory(id, directory), messageID) }

override suspend fun unrevert(id: String, directory: String) =
ready { chat.unrevert(id, sessions.getDirectory(id, directory)) }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,32 @@ class KiloBackendChatManagerTest {
assertEquals("{}", mock.lastUnrevertBody)
}

@Test
fun `delete message sends queued message delete request`() = runBlocking {
val port = mock.start()
val chat = KiloBackendChatManager(scope, TestLog())
chat.start(OkHttpClient(), port, MutableSharedFlow())

val result = chat.deleteMessage("ses_abc", "/test/project", "msg1")

assertTrue(result)
assertEquals(1, mock.requestCount("/session/ses_abc/message/msg1"))
assertTrue(mock.lastMessageDeletePath!!.startsWith("/session/ses_abc/message/msg1?directory="))
}

@Test
fun `delete message returns false for queued drop miss`() = runBlocking {
val port = mock.start()
val chat = KiloBackendChatManager(scope, TestLog())
chat.start(OkHttpClient(), port, MutableSharedFlow())
mock.messageDeleteResponse = "false"

val result = chat.deleteMessage("ses_abc", "/test/project", "msg1")

assertEquals(false, result)
assertEquals(1, mock.requestCount("/session/ses_abc/message/msg1"))
}

@Test
fun `revert failure throws on non successful response`() = runBlocking {
val port = mock.start()
Expand Down Expand Up @@ -202,4 +228,21 @@ class KiloBackendChatManagerTest {
assertEquals("ses_abc", event.sessionID)
assertTrue(log.messages.any { it.contains("route=chat-events parse=false type=session.error") }, log.messages.joinToString("\n"))
}

@Test
fun `global message event type is extracted from payload`() = runBlocking {
val port = mock.start()
val sse = MutableSharedFlow<SseEvent>(replay = 8)
val chat = KiloBackendChatManager(scope, TestLog())
chat.start(OkHttpClient(), port, sse)

val received = async(start = CoroutineStart.UNDISPATCHED) { withTimeout(5_000) { chat.events.first() } }
withTimeout(5_000) { sse.subscriptionCount.first { it > 0 } }
sse.emit(SseEvent("message", """{"payload":{"type":"session.queue.changed","properties":{"sessionID":"ses_abc","queued":["msg2"]}}}"""))

val event = received.await()
assertTrue(event is ChatEventDto.SessionQueueChanged)
assertEquals("ses_abc", event.sessionID)
assertEquals(listOf("msg2"), event.queued)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,19 @@ class KiloCliDataParserTest {
assertTrue(result is ChatEventDto.SessionCompacted)
}

@Test
fun `parseChatEvent - session queue changed`() {
val data = globalEvent("""
"type": "session.queue.changed",
"properties": { "sessionID": "ses_1", "queued": ["msg2", "msg3"] }
""")
val result = KiloCliDataParser.parseChatEvent("session.queue.changed", data)
assertNotNull(result)
assertTrue(result is ChatEventDto.SessionQueueChanged)
assertEquals("ses_1", result.sessionID)
assertEquals(listOf("msg2", "msg3"), result.queued)
}

@Test
fun `parseChatEvent - session updated`() {
val data = globalEvent("""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,14 @@ class MockCliServer : AutoCloseable {
@Volatile var lastCloudSessionImportBody: String? = null
@Volatile var summarizeStatus = 200
@Volatile var revertStatus = 200
@Volatile var messageDeleteStatus = 200
@Volatile var messageDeleteResponse = "true"
@Volatile var unrevertStatus = 200
@Volatile var lastSummarizePath: String? = null
@Volatile var lastSummarizeBody: String? = null
@Volatile var lastRevertPath: String? = null
@Volatile var lastRevertBody: String? = null
@Volatile var lastMessageDeletePath: String? = null
@Volatile var lastUnrevertPath: String? = null
@Volatile var lastUnrevertBody: String? = null
@Volatile var promptStatus = 200
Expand Down Expand Up @@ -438,6 +441,10 @@ class MockCliServer : AutoCloseable {
lastRevertBody = body
respond(output, revertStatus, sessionCreate)
}
bare.matches(Regex("/session/ses_[^/]+/message/[^/]+")) && method == "DELETE" -> {
lastMessageDeletePath = path
respond(output, messageDeleteStatus, messageDeleteResponse)
}
bare.matches(Regex("/session/ses_[^/]+/unrevert")) && method == "POST" -> {
lastUnrevertPath = path
lastUnrevertBody = body
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@ class KiloSessionService internal constructor(
log.info("${ChatLogSummary.sid(id)} kind=revert ok=true")
}

suspend fun deleteMessage(id: String, dir: String, message: String): Boolean =
call { deleteMessage(id, dir, message) }

suspend fun unrevert(id: String, dir: String) {
call { unrevert(id, dir) }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ class SessionUi(
resize = { anchor, fn -> scroll.preserve(anchor, fn) },
revert = ::revert,
cancelRevert = ::cancelRevert,
deleteQueued = { id -> controller.deleteQueuedMessage(id) },
banner = RevertBanner(controller.model, ::redo, controller::redoAll, ::cancelRevert, focus),
).also {
it.onHover = { view, on -> if (on) popup.show(view) else popup.notifyExit(view) }
Expand Down Expand Up @@ -543,6 +544,8 @@ class SessionUi(

is SessionModelEvent.RevertChanged -> onRevertChanged(event.revert)

is SessionModelEvent.QueueChanged -> Unit

is SessionModelEvent.TurnAdded,
is SessionModelEvent.TurnUpdated,
is SessionModelEvent.ContentAdded,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,27 @@ class SessionController(
}
}

fun deleteQueuedMessage(message: String) {
assertEdt()
val id = sid ?: return
cs.launch {
try {
val ok = sessions.deleteMessage(id, directory, message)
if (!ok) {
capture("Session Error", sessionProps(id) + mapOf("context" to "delete-message", "errorClass" to "DeleteMiss"))
LOG.warn("${ChatLogSummary.sid(id)} kind=deleteMessage missed message=$message")
return@launch
}
capture("Conversation Queued Message Removed", sessionProps(id))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
capture("Session Error", sessionProps(id) + mapOf("context" to "delete-message", "errorClass" to e::class.java.name))
LOG.warn("${ChatLogSummary.sid(id)} kind=deleteMessage failed message=${e.message}", e)
}
}
}

fun unrevert() {
assertEdt()
val id = sid ?: return
Expand Down Expand Up @@ -1368,6 +1389,8 @@ class SessionController(
idle()
}

is ChatEventDto.SessionQueueChanged -> updateModel { model.setQueued(event.queued.toSet()) }

is ChatEventDto.SessionCompacted -> {
capture("Context Condensed", sessionProps(event.sessionID))
model.markCompacted()
Expand Down Expand Up @@ -1406,7 +1429,8 @@ class SessionController(
is ChatEventDto.QuestionRejected,
is ChatEventDto.SessionStatusChanged,
is ChatEventDto.SessionUpdated,
is ChatEventDto.SessionIdle -> {
is ChatEventDto.SessionIdle,
is ChatEventDto.SessionQueueChanged -> {
edt {
if (disposed) return@edt
updateModel { handleMetadata(event) }
Expand All @@ -1428,6 +1452,7 @@ class SessionController(
is ChatEventDto.SessionStatusChanged -> status(event.status)
is ChatEventDto.SessionUpdated -> model.setSession(event.session)
is ChatEventDto.SessionIdle -> idle()
is ChatEventDto.SessionQueueChanged -> model.setQueued(event.queued.toSet())
else -> Unit
}
}
Expand Down Expand Up @@ -2312,6 +2337,7 @@ private fun matchesSession(event: ChatEventDto, id: String): Boolean = when (eve
is ChatEventDto.SessionStatusChanged -> event.sessionID == id
is ChatEventDto.SessionUpdated -> event.sessionID == id
is ChatEventDto.SessionIdle -> event.sessionID == id
is ChatEventDto.SessionQueueChanged -> event.sessionID == id
is ChatEventDto.SessionCompacted -> event.sessionID == id
is ChatEventDto.SessionDiffChanged -> event.sessionID == id
is ChatEventDto.TodoUpdated -> event.sessionID == id
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ class SessionModel {

private var revert: SessionRevertDto? = null

var queued: Set<String> = emptySet()
private set

var header: SessionHeaderSnapshot = emptyHeader()
private set

Expand Down Expand Up @@ -125,6 +128,9 @@ class SessionModel {
return idx >= 0 && pos >= idx
}

@RequiresEdt
fun isQueued(id: String): Boolean = id in queued

@RequiresEdt
fun turn(id: String): Turn? = turnEntries[id]

Expand Down Expand Up @@ -295,6 +301,13 @@ class SessionModel {
fire(SessionModelEvent.RevertChanged(revert))
}

@RequiresEdt
fun setQueued(ids: Set<String>) {
if (queued == ids) return
queued = ids
fire(SessionModelEvent.QueueChanged(ids))
}

@RequiresEdt
fun setDiff(diff: List<DiffFileDto>) {
this.diff = diff
Expand Down Expand Up @@ -329,6 +342,7 @@ class SessionModel {
hiddenText.clear()
session = null
revert = null
queued = emptySet()
state = SessionState.Idle
diff = emptyList()
todos = emptyList()
Expand Down Expand Up @@ -363,6 +377,7 @@ class SessionModel {
hiddenText.clear()
session = null
revert = null
queued = emptySet()
state = SessionState.Idle
diff = emptyList()
todos = emptyList()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ sealed class SessionModelEvent {
data class RevertChanged(val revert: SessionRevertDto?) : SessionModelEvent() {
override fun toString() = "RevertChanged ${revert?.messageID ?: "none"}"
}
data class QueueChanged(val queued: Set<String>) : SessionModelEvent() {
override fun toString() = "QueueChanged [${queued.sorted().joinToString(", ")}]"
}
data class HeaderUpdated(val header: SessionHeaderSnapshot) : SessionModelEvent() {
override fun toString() = "HeaderUpdated visible=${header.visible}"
}
Expand Down
Loading
Loading