fix(jetbrains): attach source metadata to git changes

This commit is contained in:
kirillk
2026-06-22 17:40:59 -04:00
parent ce8e4eeb21
commit 45d3fbaedb
11 changed files with 115 additions and 13 deletions
@@ -168,7 +168,8 @@ class KiloBackendChatManager(
val raw = response.body?.string()
log.warn("prompt_async failed: HTTP $code")
raw?.let { log.debug { "${ChatLogSummary.sid(id)} kind=prompt op=prompt_async error=${ChatLogSummary.body(it)}" } }
throw RuntimeException("prompt_async failed: HTTP $code")
val detail = raw?.takeIf { it.isNotBlank() }?.let { ": ${ChatLogSummary.body(it)}" }.orEmpty()
throw RuntimeException("prompt_async failed: HTTP $code$detail")
}
log.debug { "${ChatLogSummary.sid(id)} kind=prompt op=prompt_async ok=true code=$code" }
}
@@ -3,6 +3,8 @@ package ai.kilocode.backend.app
import ai.kilocode.backend.testing.MockCliServer
import ai.kilocode.backend.testing.TestLog
import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.PromptPartDto
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -75,6 +77,22 @@ class KiloBackendChatManagerTest {
assertEquals("Enhance prompt failed: HTTP 500", error.message)
}
@Test
fun `prompt failure includes CLI response body summary`() {
val port = mock.start()
val chat = KiloBackendChatManager(scope, TestLog())
chat.start(OkHttpClient(), port, MutableSharedFlow())
mock.promptStatus = 400
mock.promptResponse = """{"issues":[{"message":"invalid source type"}]}"""
val error = assertFailsWith<RuntimeException> {
chat.prompt("ses_abc", "/test/project", PromptDto(parts = listOf(PromptPartDto(type = "text", text = "hello"))))
}
assertTrue(error.message!!.contains("prompt_async failed: HTTP 400"), error.message)
assertTrue(error.message!!.contains("chars="), error.message)
}
@Test
fun `enhance prompt cancels the HTTP request with its coroutine`() = runBlocking {
val port = mock.start()
@@ -1715,18 +1715,23 @@ class KiloCliDataParserTest {
}
@Test
fun `buildPromptJson - data file part without source omits source metadata`() {
fun `buildPromptJson - data file part includes source metadata`() {
val prompt = PromptDto(parts = listOf(PromptPartDto(
type = "file",
mime = "text/plain",
url = "data:text/plain;charset=utf-8,diff%20content",
filename = "git-changes.txt",
source = PartSourceDto(
type = "file",
text = PartSourceTextDto("@git-changes", 7.0, 19.0),
path = "git-changes",
),
)))
val result = KiloCliDataParser.buildPromptJson(prompt)
assertEquals(
"""{"parts":[{"type":"file","mime":"text/plain","url":"data:text/plain;charset=utf-8,diff%20content","filename":"git-changes.txt"}]}""",
"""{"parts":[{"type":"file","mime":"text/plain","url":"data:text/plain;charset=utf-8,diff%20content","filename":"git-changes.txt","source":{"type":"file","text":{"value":"@git-changes","start":7.0,"end":19.0},"path":"git-changes"}}]}""",
result,
)
}
@@ -93,6 +93,10 @@ class MockCliServer : AutoCloseable {
@Volatile var summarizeStatus = 200
@Volatile var lastSummarizePath: String? = null
@Volatile var lastSummarizeBody: String? = null
@Volatile var promptStatus = 200
@Volatile var promptResponse = "true"
@Volatile var lastPromptPath: String? = null
@Volatile var lastPromptBody: String? = null
@Volatile var enhanced = """{"text":"Enhanced prompt"}"""
@Volatile var enhanceStatus = 200
@Volatile var lastEnhancePath: String? = null
@@ -342,11 +346,11 @@ class MockCliServer : AutoCloseable {
bare == "/session/status" -> respond(output, sessionStatusesStatus, sessionStatuses)
bare == "/session" && method == "GET" -> respond(output, sessionsStatus, sessions)
bare == "/session" && method == "POST" -> respond(output, sessionCreateStatus, sessionCreate)
bare.matches(Regex("/session/ses_.+")) && !bare.contains("/summarize") && method == "GET" ->
bare.matches(Regex("/session/ses_[^/]+")) && method == "GET" ->
respond(output, sessionGetStatus, sessionCreate)
bare.matches(Regex("/session/ses_.+")) && !bare.contains("/summarize") && method == "DELETE" ->
bare.matches(Regex("/session/ses_[^/]+")) && method == "DELETE" ->
respond(output, sessionDeleteStatus, "true")
bare.matches(Regex("/session/ses_.+")) && !bare.contains("/summarize") && method == "PATCH" -> {
bare.matches(Regex("/session/ses_[^/]+")) && method == "PATCH" -> {
lastSessionRenamePath = path
lastSessionRenameBody = body
lastSessionRenameMethod = method
@@ -357,6 +361,11 @@ class MockCliServer : AutoCloseable {
lastSummarizeBody = body
respond(output, summarizeStatus, summarizeResponse)
}
bare.matches(Regex("/session/ses_[^/]+/prompt_async")) && method == "POST" -> {
lastPromptPath = path
lastPromptBody = body
respond(output, promptStatus, promptResponse)
}
bare == "/enhance-prompt" && method == "POST" -> {
lastEnhancePath = path
lastEnhanceBody = body
@@ -27,10 +27,9 @@ fun mentionFileParts(text: String, paths: Set<String>, directory: String): List<
fun gitChangesPart(text: String, diff: String?): PromptPartDto? {
val spec = MentionAction.GIT_CHANGES
val raw = spec.token
val start = text.mentionStart(raw) ?: return null
val start = text.mentionStart(spec.token) ?: return null
val value = diff?.takeIf { it.isNotBlank() } ?: return null
return dataPart(spec.filename, value)
return dataPart(spec.filename, value, source("file", spec.token, start, path = spec.uri))
}
private fun String.mentionStart(token: String): Int? {
@@ -48,7 +47,12 @@ private fun dataPart(name: String, text: String, source: PartSourceDto? = null):
return PromptPartDto(type = "file", mime = "text/plain", url = "data:text/plain;charset=utf-8,$data", filename = name, source = source)
}
private fun source(type: String, token: String, start: Int, path: String? = null) = PartSourceDto(
private fun source(
type: String,
token: String,
start: Int,
path: String? = null,
) = PartSourceDto(
type = type,
text = PartSourceTextDto(value = token, start = start.toDouble(), end = (start + token.length).toDouble()),
path = path,
@@ -8,6 +8,7 @@ data class PromptMention(
val path: String,
val start: Int,
val end: Int,
val attachment: FileAttachment? = null,
)
fun promptMentions(msg: Message): List<PromptMention> = msg.parts.values.mapNotNull { part ->
@@ -20,6 +21,7 @@ fun promptMentions(msg: Message): List<PromptMention> = msg.parts.values.mapNotN
path = path,
start = source.text.start.toInt(),
end = source.text.end.toInt(),
attachment = part,
)
}
@@ -1,6 +1,7 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Text
import ai.kilocode.client.session.model.FileAttachment
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
@@ -11,6 +12,7 @@ import com.intellij.util.ui.JBUI
class PromptView(
text: Text,
private val openFile: (String) -> Unit = {},
private val openAttachment: (FileAttachment) -> Unit = {},
openUrl: (String) -> Unit = {},
selection: SessionSelection? = null,
mentions: List<PromptMention> = emptyList(),
@@ -49,6 +51,10 @@ class PromptView(
override fun onLink(href: String) {
val mention = mentions.firstOrNull { it.path == href || path(it.path) == href }
if (mention != null) {
mention.attachment?.let {
openAttachment(it)
return
}
openFile(mention.path)
return
}
@@ -73,7 +73,7 @@ object ViewFactory {
mentions: List<PromptMention> = emptyList(),
openAttachment: (FileAttachment) -> Unit = { AttachmentView.openDefault(it, openFile, openUrl) },
): PartView = when (content) {
is Text -> PromptView(content, openFile = openFile, openUrl = openUrl, selection = selection, mentions = mentions)
is Text -> PromptView(content, openFile = openFile, openAttachment = openAttachment, openUrl = openUrl, selection = selection, mentions = mentions)
else -> create(content, openFile, openUrl, selection, repo, openAttachment)
}
@@ -297,6 +297,32 @@ class SessionUiUpdateTest : BasePlatformTestCase() {
assertEquals(0, msg.components.filterIsInstance<PromptAttachmentView>().size)
}
fun `test user git changes mention hides synthetic data attachment card`() {
model.upsertMessage(msg("u1", "user"))
model.updateContent("u1", part("p1", "u1", "text", text = "review @git-changes"))
model.updateContent("u1", PartDto(
id = "f1",
sessionID = "ses",
messageID = "u1",
type = "file",
mime = "text/plain",
url = "data:text/plain;charset=utf-8,diff%20content",
filename = "git-changes.txt",
source = PartSourceDto(
type = "file",
text = PartSourceTextDto("@git-changes", 7.0, 19.0),
path = "git-changes",
),
))
val msg = panel.findMessage("u1")!!
assertEquals(listOf("p1"), msg.partIds())
assertNull(msg.part("f1"))
assertEquals("review [@git-changes](git-changes)", (msg.part("p1") as TextView).markdown())
assertEquals(0, msg.components.filterIsInstance<PromptAttachmentView>().size)
}
fun `test source less text attachment still renders in prompt strip`() {
model.upsertMessage(msg("u1", "user"))
model.updateContent("u1", PartDto(
@@ -49,7 +49,13 @@ class PromptMentionPartsTest : BasePlatformTestCase() {
assertEquals("text/plain", part.mime)
assertEquals(MentionAction.GIT_CHANGES.filename, part.filename)
assertEquals("data:text/plain;charset=utf-8,hello%20world%2Bplus", part.url)
assertNull(part.source)
assertEquals("file", part.source?.type)
assertEquals(MentionAction.GIT_CHANGES.uri, part.source?.path)
assertNull(part.source?.clientName)
assertNull(part.source?.uri)
assertEquals(MentionAction.GIT_CHANGES.token, part.source?.text?.value)
assertEquals(7.0, part.source?.text?.start)
assertEquals(19.0, part.source?.text?.end)
}
fun `test gitChangesPart ignores missing blank and non boundary matches`() {
@@ -317,7 +317,13 @@ class TextViewTest : BasePlatformTestCase() {
msg.parts["blank"] = file("blank", "text/plain", "@src/b.kt", "", 0, 9)
msg.parts["plain"] = FileAttachment("plain").also { it.mime = "text/plain" }
assertEquals(listOf(PromptMention("@src/a.kt", "src/a.kt", 0, 9)), promptMentions(msg))
val mentions = promptMentions(msg)
assertEquals(1, mentions.size)
assertEquals("@src/a.kt", mentions.single().token)
assertEquals("src/a.kt", mentions.single().path)
assertEquals(0, mentions.single().start)
assertEquals(9, mentions.single().end)
assertSame(msg.parts["keep"], mentions.single().attachment)
}
fun `test prompt view renders mention as link`() {
@@ -347,6 +353,25 @@ class TextViewTest : BasePlatformTestCase() {
assertEquals(listOf("https://kilocode.ai/docs"), urls)
}
fun `test prompt view routes attachment backed mention link to attachment opener`() {
val opened = mutableListOf<FileAttachment>()
val item = file("f1", "text/plain", "@git-changes", "git-changes", 7, 19).also {
it.url = "data:text/plain;charset=utf-8,diff%20content"
it.filename = "git-changes.txt"
}
val text = Text("p1").also { it.content.append("review @git-changes") }
val view = PromptView(
text,
openFile = { error("should not open file") },
openAttachment = { opened.add(it) },
mentions = listOf(PromptMention("@git-changes", "git-changes", 7, 19, item)),
)
view.simulateLink("git-changes")
assertEquals(listOf(item), opened)
}
fun `test prompt view setMentions refreshes existing prompt`() {
val text = Text("p1").also { it.content.append("read @src/a.kt") }
val view = PromptView(text)