fix(chat): preserve attachment references from JSON RPC results (#1210)
Co-authored-by: M57 <41645758+Hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
799bb2959e
commit
83b2a8fa9b
4 files changed
+168
-4
No files matched your search
@@ -196,7 +196,14 @@ internal fun mapServerMessages(
|
|||||||
return@forEachIndexed
|
return@forEachIndexed
|
||||||
}
|
}
|
||||||
|
|
||||||
var finalContent = rawContent
|
// Context-reference preprocessing can persist model-facing attachment
|
||||||
|
// contents after the user's text. A cold reload / second device has no
|
||||||
|
// optimistic local bubble to hide that enriched REST representation,
|
||||||
|
// so project the producer-owned suffix out at hydration time too.
|
||||||
|
// Keep the actual @file:/@image: token here: unlike the dedupe path,
|
||||||
|
// REST-only hydration cannot prove that reference was not user-authored.
|
||||||
|
var finalContent =
|
||||||
|
if (role == MessageRole.USER) stripGatewayAttachedContext(rawContent) else rawContent
|
||||||
var attachments: List<Attachment>? = null
|
var attachments: List<Attachment>? = null
|
||||||
if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) {
|
if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) {
|
||||||
val items = HostMediaExtractor.extract(rawContent)
|
val items = HostMediaExtractor.extract(rawContent)
|
||||||
|
|||||||
@@ -6,6 +6,38 @@ import kotlinx.serialization.json.JsonElement
|
|||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
|
private val ATTACHED_CONTEXT_MARKER_RE =
|
||||||
|
Regex("""(?:^|\n)--- Attached Context ---\s*\n""")
|
||||||
|
|
||||||
|
private val CONTEXT_REF_RE =
|
||||||
|
Regex(
|
||||||
|
"""@(file|folder|url|image|tool|terminal):(?:"[^"\n]+"|'[^'\n]+'|`[^`\n]+`|\S+)""",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a gateway-generated `--- Attached Context ---` suffix while
|
||||||
|
* preserving the user-authored portion of the message.
|
||||||
|
*
|
||||||
|
* The marker itself is plain text a user can legitimately type, so do not
|
||||||
|
* treat it as a producer boundary unless the suffix also contains a context
|
||||||
|
* reference of the shape emitted by the gateway.
|
||||||
|
*
|
||||||
|
* Unlike [stripAttachmentRefLines], this intentionally keeps `@file:` /
|
||||||
|
* `@image:` references in the visible text. REST-only hydration has no local
|
||||||
|
* optimistic bubble proving those references were injected by Mobile, and
|
||||||
|
* users may have authored context references themselves.
|
||||||
|
*/
|
||||||
|
internal fun stripGatewayAttachedContext(content: String): String {
|
||||||
|
val marker = ATTACHED_CONTEXT_MARKER_RE.find(content) ?: return content
|
||||||
|
val attachedContext = content.substring(marker.range.last + 1)
|
||||||
|
|
||||||
|
if (!CONTEXT_REF_RE.containsMatchIn(attachedContext)) {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
return content.substring(0, marker.range.first).trimEnd()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Canonical comparison key for a tool message's result payload.
|
* Canonical comparison key for a tool message's result payload.
|
||||||
*
|
*
|
||||||
@@ -127,7 +159,7 @@ internal fun sameLogicalMessage(
|
|||||||
* run_agent). Blank lines left behind are dropped too.
|
* run_agent). Blank lines left behind are dropped too.
|
||||||
*/
|
*/
|
||||||
internal fun stripAttachmentRefLines(content: String): String =
|
internal fun stripAttachmentRefLines(content: String): String =
|
||||||
content
|
stripGatewayAttachedContext(content)
|
||||||
.lines()
|
.lines()
|
||||||
.map { it.trim() }
|
.map { it.trim() }
|
||||||
.filterNot { line ->
|
.filterNot { line ->
|
||||||
|
|||||||
@@ -1877,7 +1877,7 @@ class ChatViewModel(
|
|||||||
"filename" to attachment.name,
|
"filename" to attachment.name,
|
||||||
"ext" to attachment.fileExtension,
|
"ext" to attachment.fileExtension,
|
||||||
),
|
),
|
||||||
)
|
).let { if (it is JsonElement) it.toAny() else it }
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val ok = (result as? Map<String, Any?>)?.get("attached") as? Boolean
|
val ok = (result as? Map<String, Any?>)?.get("attached") as? Boolean
|
||||||
@@ -1897,7 +1897,10 @@ class ChatViewModel(
|
|||||||
"data_url" to "data:${attachment.mimeType};base64,$b64",
|
"data_url" to "data:${attachment.mimeType};base64,$b64",
|
||||||
"name" to attachment.name,
|
"name" to attachment.name,
|
||||||
),
|
),
|
||||||
)?.let { result ->
|
)?.let { response ->
|
||||||
|
// request() returns raw JsonElement values; normalize before reading Kotlin types.
|
||||||
|
val result = if (response is JsonElement) response.toAny() else response
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val refText =
|
val refText =
|
||||||
(result as? Map<String, Any?>)?.get("ref_text") as? String
|
(result as? Map<String, Any?>)?.get("ref_text") as? String
|
||||||
|
|||||||
@@ -120,6 +120,100 @@ class ChatViewModelTest {
|
|||||||
assertEquals("ws-local-1", merged.single().id)
|
assertEquals("ws-local-1", merged.single().id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mergeTranscriptWithLive_collapsesGatewayAttachmentContextCopy() {
|
||||||
|
val local =
|
||||||
|
ChatMessage(
|
||||||
|
id = "ws-local-attachment",
|
||||||
|
role = MessageRole.USER,
|
||||||
|
content = "What is the secret word in the attached file?",
|
||||||
|
timestamp = 100L,
|
||||||
|
)
|
||||||
|
val rest =
|
||||||
|
ChatMessage(
|
||||||
|
id = "rest-sess-attachment",
|
||||||
|
role = MessageRole.USER,
|
||||||
|
content =
|
||||||
|
"""
|
||||||
|
@file:files/agent-vault/hermes/attachments/note.txt
|
||||||
|
|
||||||
|
What is the secret word in the attached file?
|
||||||
|
|
||||||
|
--- Attached Context ---
|
||||||
|
|
||||||
|
📄 @file:files/agent-vault/hermes/attachments/note.txt (8 tokens)
|
||||||
|
```
|
||||||
|
THE_SECRET_WORD_IS_MANGO_8421
|
||||||
|
```
|
||||||
|
""".trimIndent(),
|
||||||
|
timestamp = 100L,
|
||||||
|
)
|
||||||
|
|
||||||
|
val merged = mergeTranscriptWithLive(listOf(rest), listOf(local))
|
||||||
|
|
||||||
|
assertEquals(1, merged.size)
|
||||||
|
assertEquals("ws-local-attachment", merged.single().id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mapServerMessages_restOnlyAttachmentHidesGatewayContext() {
|
||||||
|
val ref = "@file:files/agent-vault/hermes/attachments/note.txt"
|
||||||
|
val persisted =
|
||||||
|
"""
|
||||||
|
$ref
|
||||||
|
|
||||||
|
What is the secret word in the attached file?
|
||||||
|
|
||||||
|
--- Attached Context ---
|
||||||
|
|
||||||
|
📄 $ref (8 tokens)
|
||||||
|
```
|
||||||
|
THE_SECRET_WORD_IS_MANGO_8421
|
||||||
|
```
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val mapped =
|
||||||
|
mapServerMessages(
|
||||||
|
sessionId = "session-1",
|
||||||
|
messages =
|
||||||
|
listOf(
|
||||||
|
SessionMessage(
|
||||||
|
id = 42,
|
||||||
|
role = "user",
|
||||||
|
content = JsonPrimitive(persisted),
|
||||||
|
timestamp = JsonPrimitive("100"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
offset = 0,
|
||||||
|
latestPaging = true,
|
||||||
|
liveMessages = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, mapped.size)
|
||||||
|
assertEquals(
|
||||||
|
"""
|
||||||
|
$ref
|
||||||
|
|
||||||
|
What is the secret word in the attached file?
|
||||||
|
""".trimIndent(),
|
||||||
|
mapped.single().content,
|
||||||
|
)
|
||||||
|
assertFalse(mapped.single().content.contains("--- Attached Context ---"))
|
||||||
|
assertFalse(mapped.single().content.contains("THE_SECRET_WORD_IS_MANGO_8421"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stripAttachmentRefLines_preservesUserAuthoredAttachedContextHeading() {
|
||||||
|
val authored =
|
||||||
|
"""
|
||||||
|
Explain this heading:
|
||||||
|
--- Attached Context ---
|
||||||
|
this is ordinary user-authored text
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
assertEquals(authored, stripAttachmentRefLines(authored))
|
||||||
|
}
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
Dispatchers.setMain(testDispatcher)
|
Dispatchers.setMain(testDispatcher)
|
||||||
@@ -1981,6 +2075,34 @@ class ChatViewModelTest {
|
|||||||
verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) }
|
verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sendMessage_rawJsonFileAttachmentRetainsReference() =
|
||||||
|
runTest {
|
||||||
|
val (viewModel, _) = createViewModelWithSession()
|
||||||
|
val uriString = "content://test/note"
|
||||||
|
val uri = mockk<Uri>()
|
||||||
|
val resolver = mockk<ContentResolver>()
|
||||||
|
mockkStatic(Uri::class)
|
||||||
|
every { Uri.parse(uriString) } returns uri
|
||||||
|
every { app.contentResolver } returns resolver
|
||||||
|
every { resolver.openInputStream(uri) } answers { "hello".byteInputStream() }
|
||||||
|
every { HermesWsClient.request(WsMethods.FILE_ATTACH, any(), any()) } returns
|
||||||
|
CompletableDeferred<Any?>(
|
||||||
|
buildJsonObject {
|
||||||
|
put("attached", true)
|
||||||
|
put("ref_text", "@file:attachments/note.txt")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
viewModel.addAttachment(uriString, "note.txt", "text/plain", 5)
|
||||||
|
|
||||||
|
viewModel.sendMessage("Inspect file")
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
verify {
|
||||||
|
HermesWsClient.sendMessage(any(), "@file:attachments/note.txt\n\nInspect file", any(), any())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun sendMessage_laterGrowingAttachmentDoesNotPartiallyUploadEarlierAttachments() =
|
fun sendMessage_laterGrowingAttachmentDoesNotPartiallyUploadEarlierAttachments() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|||||||
Reference in new issue
Block a user