diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt index b9959c8912..19a399cdca 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt @@ -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" } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt index a38b13d106..29b0ec0350 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt @@ -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 { + 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() diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 9422e61a53..3d96d25710 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -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, ) } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index 078cf52c7d..f9380d9c89 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptMentionParts.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptMentionParts.kt index 76c89d477c..1b9166e53d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptMentionParts.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptMentionParts.kt @@ -27,10 +27,9 @@ fun mentionFileParts(text: String, paths: Set, 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, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptMention.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptMention.kt index 96d0ec3804..a9a3af505d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptMention.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptMention.kt @@ -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 = msg.parts.values.mapNotNull { part -> @@ -20,6 +21,7 @@ fun promptMentions(msg: Message): List = msg.parts.values.mapNotN path = path, start = source.text.start.toInt(), end = source.text.end.toInt(), + attachment = part, ) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptView.kt index d4f04901e9..958a8c6cb1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptView.kt @@ -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 = 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 } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt index 0aad08fd1b..871004d2c2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt @@ -73,7 +73,7 @@ object ViewFactory { mentions: List = 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) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt index 23fcf73b17..59ef3d9759 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt @@ -297,6 +297,32 @@ class SessionUiUpdateTest : BasePlatformTestCase() { assertEquals(0, msg.components.filterIsInstance().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().size) + } + fun `test source less text attachment still renders in prompt strip`() { model.upsertMessage(msg("u1", "user")) model.updateContent("u1", PartDto( diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/PromptMentionPartsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/PromptMentionPartsTest.kt index 3cfeefc760..9ab944056c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/PromptMentionPartsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/PromptMentionPartsTest.kt @@ -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`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt index bf67be1240..9165c7d3ac 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt @@ -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() + 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)