mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
fix(jetbrains): attach source metadata to git changes
This commit is contained in:
+2
-1
@@ -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" }
|
||||
}
|
||||
|
||||
+18
@@ -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()
|
||||
|
||||
+7
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+12
-3
@@ -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
|
||||
|
||||
+8
-4
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+26
@@ -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(
|
||||
|
||||
+7
-1
@@ -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`() {
|
||||
|
||||
+26
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user