From 89a14eb765830f166360503cfee47edfe5235851 Mon Sep 17 00:00:00 2001 From: AlexDev_ <56083016+alexdev03@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:01:56 +0200 Subject: [PATCH] fix(chat): bound attachment encoding and recover rejected sends --- .../ui/chat/AttachmentBase64Encoder.kt | 35 ++ .../hermescontrol/ui/chat/ChatInputPolicy.kt | 14 + .../m57/hermescontrol/ui/chat/ChatScreen.kt | 6 + .../hermescontrol/ui/chat/ChatViewModel.kt | 322 ++++++++++++----- .../ui/chat/AttachmentBase64EncoderTest.kt | 37 ++ .../ui/chat/ChatInputPolicyTest.kt | 12 + .../ui/chat/ChatViewModelTest.kt | 327 ++++++++++++++++++ 7 files changed, 657 insertions(+), 96 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/AttachmentBase64Encoder.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/AttachmentBase64EncoderTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/AttachmentBase64Encoder.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/AttachmentBase64Encoder.kt new file mode 100644 index 00000000..ac3cb61a --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/AttachmentBase64Encoder.kt @@ -0,0 +1,35 @@ +package com.m57.hermescontrol.ui.chat + +import java.io.InputStream +import java.io.OutputStream +import java.util.Base64 + +/** Keeps the encoded JSON-RPC frame safely below the WebSocket's 16 MiB limit. */ +internal const val MAX_CHAT_ATTACHMENT_BYTES = 10L * 1024L * 1024L + +internal enum class AttachmentSizeResult { + WITHIN_LIMIT, + TOO_LARGE, +} + +/** Streams Base64 to [output] while reading at most [maxBytes] of raw input. */ +internal fun encodeAttachmentBase64( + input: InputStream, + output: OutputStream, + maxBytes: Long = MAX_CHAT_ATTACHMENT_BYTES, +): AttachmentSizeResult { + require(maxBytes >= 0) { "maxBytes must be non-negative" } + + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var totalBytes = 0L + + Base64.getEncoder().wrap(output).use { encodedOutput -> + while (true) { + val bytesRead = input.read(buffer) + if (bytesRead == -1) return AttachmentSizeResult.WITHIN_LIMIT + totalBytes += bytesRead + if (totalBytes > maxBytes) return AttachmentSizeResult.TOO_LARGE + encodedOutput.write(buffer, 0, bytesRead) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatInputPolicy.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatInputPolicy.kt index 7f843f42..ec4b6d19 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatInputPolicy.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatInputPolicy.kt @@ -46,6 +46,20 @@ object ChatInputPolicy { */ fun commandFieldValue(command: String): TextFieldValue = TextFieldValue(command, TextRange(command.length)) + /** Restores a rejected prompt without discarding text typed since dispatch. */ + fun restoreRejectedText( + rejectedText: String, + current: TextFieldValue, + ): TextFieldValue { + val restored = + when { + current.text.isEmpty() -> rejectedText + rejectedText.isEmpty() -> current.text + else -> "$rejectedText\n${current.text}" + } + return commandFieldValue(restored) + } + /** * Rank slash-command suggestions by how often the user has dispatched them * (issue #865): most-used first, ties broken by the current (catalog) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 68d4f47f..7fed197f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -298,6 +298,12 @@ fun ChatScreen( viewModel.consumePendingPrefill() } } + LaunchedEffect(state.composerTextToRestore) { + state.composerTextToRestore?.let { text -> + inputFieldValue = ChatInputPolicy.restoreRejectedText(text, inputFieldValue) + viewModel.consumeComposerTextRestore() + } + } var isListening by rememberSaveable { mutableStateOf(false) } var lastAnimatedMessageId by rememberSaveable { mutableStateOf(null) } var showReloginDialog by rememberSaveable { mutableStateOf(false) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 405d54f3..e718f7b3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -2,8 +2,6 @@ package com.m57.hermescontrol.ui.chat import android.app.Application import android.net.Uri -import android.util.Base64 -import android.util.Base64OutputStream import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope @@ -43,7 +41,6 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.coroutines.yield import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.intOrNull @@ -52,12 +49,27 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONObject -import java.io.ByteArrayOutputStream +import java.io.File import java.util.concurrent.ConcurrentHashMap private const val TAG = "ChatViewModel" private const val MESSAGE_PAGE_SIZE = 150 +private data class PreparedAttachment( + val attachment: Attachment, + val encodedFile: File, +) + +private sealed interface PrepareAttachmentResult { + data class Success( + val prepared: PreparedAttachment, + ) : PrepareAttachmentResult + + data object TooLarge : PrepareAttachmentResult + + data object Unreadable : PrepareAttachmentResult +} + /** * Canonical comparison key for a tool message's result payload. * @@ -349,6 +361,8 @@ data class ChatUiState( val compressionCount: Int? = null, // Attachment state val pendingAttachments: List = emptyList(), + /** One-shot composer recovery after an attachment is rejected before send. */ + val composerTextToRestore: String? = null, // Reaction animation — set when a reaction WS event arrives, auto-clears val reactionKind: String? = null, /** Monotonic trigger ID so consecutive same-kind reactions re-animate. */ @@ -519,6 +533,7 @@ class ChatViewModel( val text: String, val attachments: List, val wasStreaming: Boolean, + val userMessage: ChatMessage, ) private var pendingInitialPrompt: PendingPrompt? = null @@ -1132,6 +1147,7 @@ class ChatViewModel( wasStreaming = pending.wasStreaming, storageSessionId = storageId, agentSessionId = runtimeId, + userMessage = pending.userMessage, ) } } @@ -1436,6 +1452,20 @@ class ChatViewModel( return } + val oversizedAttachment = + _uiState.value.pendingAttachments.firstOrNull { + it.size > MAX_CHAT_ATTACHMENT_BYTES + } + if (oversizedAttachment != null) { + _uiState.update { + it.copy( + errorMessage = attachmentTooLargeMessage(oversizedAttachment), + composerTextToRestore = text, + ) + } + return + } + // Snapshot + clear attachments so the input bar empties immediately val attachments = _uiState.value.pendingAttachments.toList() clearAttachments() @@ -1463,7 +1493,7 @@ class ChatViewModel( if (storageSessionId == null || agentSessionId == null) { // Issue #969: Session creation is still in-flight. Hold the prompt // so it is dispatched automatically the moment SESSION_CREATE lands. - pendingInitialPrompt = PendingPrompt(text, attachments, wasStreaming) + pendingInitialPrompt = PendingPrompt(text, attachments, wasStreaming, userMessage) return } @@ -1485,6 +1515,7 @@ class ChatViewModel( agentSessionId: String, userMessage: ChatMessage? = null, ) { + val dispatchGeneration = sessionGeneration val msgToPersist = userMessage ?: ChatMessage( role = MessageRole.USER, @@ -1492,128 +1523,222 @@ class ChatViewModel( attachments = if (attachments.isNotEmpty()) attachments else null, ) - // Persist under the original Desktop session ID. - viewModelScope.launch(ioDispatcher) { - repo.persistMessage(msgToPersist, storageSessionId) - } - // Upload attachments then submit prompt viewModelScope.launch(ioDispatcher) { val fileRefs = mutableListOf() + val preparedAttachments = mutableListOf() - for (attachment in attachments) { - val b64 = readContentUriBase64(attachment.uri) - if (b64 == null) { - Log.w(TAG, "Skipping unreadable attachment: ${attachment.name}") - continue + try { + // Snapshot every attachment before the first RPC. This closes the + // content-URI TOCTOU window without retaining multiple Base64 + // strings in the heap: encoded snapshots live in private cache. + for (attachment in attachments) { + when (val result = prepareAttachment(attachment)) { + is PrepareAttachmentResult.Success -> { + preparedAttachments += result.prepared + } + + PrepareAttachmentResult.TooLarge -> { + rejectOversizedAttachment( + attachment = attachment, + attachments = attachments, + message = msgToPersist, + wasStreaming = wasStreaming, + generation = dispatchGeneration, + ) + return@launch + } + + PrepareAttachmentResult.Unreadable -> { + Log.w(TAG, "Skipping unreadable attachment: ${attachment.name}") + } + } } + // Persist only after every readable attachment has a bounded, + // immutable snapshot and no partial server upload can occur. try { - if (attachment.isImage) { - // Await so the backend stages the image into - // session["attached_images"] BEFORE prompt.submit runs - // (a fire-and-forget send raced prompt.submit and the - // image was dropped). Requires session_id or the gateway - // 4001s "session not found" (desktop passes it too). - val result = + repo.persistMessage(msgToPersist, storageSessionId) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + Log.e(TAG, "Failed to persist outgoing message", e) + if (dispatchGeneration == sessionGeneration) { + _uiState.update { state -> + state.copy( + messages = state.messages.filterNot { it.id == msgToPersist.id }, + pendingAttachments = attachments + state.pendingAttachments, + composerTextToRestore = msgToPersist.content, + errorMessage = "Failed to save message", + isAgentTyping = wasStreaming, + ) + } + } + return@launch + } + + for ((attachment, encodedFile) in preparedAttachments) { + try { + // Materialize one bounded Base64 payload at a time. The + // awaited RPC completes before the next snapshot is read. + val b64 = encodedFile.readText(Charsets.US_ASCII) + + if (attachment.isImage) { + // Await so the backend stages the image into + // session["attached_images"] BEFORE prompt.submit runs + // (a fire-and-forget send raced prompt.submit and the + // image was dropped). Requires session_id or the gateway + // 4001s "session not found" (desktop passes it too). + val result = + sendRpcAndAwait( + method = WsMethods.IMAGE_ATTACH_BYTES, + params = + mapOf( + "session_id" to agentSessionId, + "content_base64" to "data:${attachment.mimeType};base64,$b64", + "filename" to attachment.name, + "ext" to attachment.fileExtension, + ), + ) + if (result != null) { + @Suppress("UNCHECKED_CAST") + val ok = (result as? Map)?.get("attached") as? Boolean + if (ok != true) { + Log.w(TAG, "Image attach for ${attachment.name} returned non-ok: $result") + } + } + } else { + // Await the @file: ref text so we can embed it in the prompt. + // file.attach also requires session_id or the gateway 4001s + // "session not found" (same resolver as image.attach_bytes). sendRpcAndAwait( - method = WsMethods.IMAGE_ATTACH_BYTES, + method = WsMethods.FILE_ATTACH, params = mapOf( "session_id" to agentSessionId, - "content_base64" to "data:${attachment.mimeType};base64,$b64", - "filename" to attachment.name, - "ext" to attachment.fileExtension, + "data_url" to "data:${attachment.mimeType};base64,$b64", + "name" to attachment.name, ), - ) - if (result != null) { - @Suppress("UNCHECKED_CAST") - val ok = (result as? Map)?.get("attached") as? Boolean - if (ok != true) { - Log.w(TAG, "Image attach for ${attachment.name} returned non-ok: $result") + )?.let { result -> + @Suppress("UNCHECKED_CAST") + val refText = + (result as? Map)?.get("ref_text") as? String + if (!refText.isNullOrBlank()) fileRefs.add(refText) } } - } else { - // Await the @file: ref text so we can embed it in the prompt. - // file.attach also requires session_id or the gateway 4001s - // "session not found" (same resolver as image.attach_bytes). - sendRpcAndAwait( - method = WsMethods.FILE_ATTACH, - params = - mapOf( - "session_id" to agentSessionId, - "data_url" to "data:${attachment.mimeType};base64,$b64", - "name" to attachment.name, - ), - )?.let { result -> - @Suppress("UNCHECKED_CAST") - val refText = - (result as? Map)?.get("ref_text") as? String - if (!refText.isNullOrBlank()) fileRefs.add(refText) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + Log.e(TAG, "Failed to upload attachment ${attachment.name}", e) + if (dispatchGeneration == sessionGeneration) { + _uiState.update { + it.copy(errorMessage = "Upload failed: ${attachment.name}") + } } } - } catch (e: Exception) { - Log.e(TAG, "Failed to upload attachment ${attachment.name}", e) - _uiState.update { - it.copy(errorMessage = "Upload failed: ${attachment.name}") + } + + // Build prompt text — prepend @file: refs for non-image files + val fullText = + if (fileRefs.isEmpty()) { + text + } else { + fileRefs.joinToString("\n") + + if (text.isNotBlank()) "\n\n$text" else "" } - } - } - // Build prompt text — prepend @file: refs for non-image files - val fullText = - if (fileRefs.isEmpty()) { - text + // While a turn is actively streaming and this is a plain text prompt + // (no attachments — session.redirect carries text only), steer the + // in-flight turn via session.redirect instead of queueing a fresh + // prompt.submit. The backend rewrites the live turn when it can, or + // queues the correction as the next turn otherwise (issue #710). + if (dispatchGeneration == sessionGeneration) { + ActiveSessionHolder.set(agentSessionId, storageSessionId) + } + if (wasStreaming && attachments.isEmpty()) { + wsClient.sendRedirect( + agentSessionId, + fullText, + onSent = { id -> + trackSessionRequest( + id = id, + method = WsMethods.SESSION_REDIRECT, + generation = dispatchGeneration, + sessionId = storageSessionId, + ) + }, + ) } else { - fileRefs.joinToString("\n") + - if (text.isNotBlank()) "\n\n$text" else "" + wsClient.sendMessage( + agentSessionId, + fullText, + onSent = { id -> + trackSessionRequest( + id = id, + method = WsMethods.PROMPT_SUBMIT, + generation = dispatchGeneration, + sessionId = storageSessionId, + ) + }, + ) } - - // While a turn is actively streaming and this is a plain text prompt - // (no attachments — session.redirect carries text only), steer the - // in-flight turn via session.redirect instead of queueing a fresh - // prompt.submit. The backend rewrites the live turn when it can, or - // queues the correction as the next turn otherwise (issue #710). - ActiveSessionHolder.set(agentSessionId, storageSessionId) - if (wasStreaming && attachments.isEmpty()) { - wsClient.sendRedirect( - agentSessionId, - fullText, - onSent = { id -> trackRequest(id, WsMethods.SESSION_REDIRECT) }, - ) - } else { - wsClient.sendMessage( - agentSessionId, - fullText, - onSent = { id -> trackRequest(id, WsMethods.PROMPT_SUBMIT) }, - ) + } finally { + preparedAttachments.forEach { it.encodedFile.delete() } } } } - /** Read and encode a `content://` or `file://` URI to Base64 via ContentResolver, avoiding large allocations. */ - private suspend fun readContentUriBase64(uriString: String): String? = - try { + /** Snapshot one URI to private cache while enforcing the outbound frame limit. */ + private suspend fun prepareAttachment(attachment: Attachment): PrepareAttachmentResult { + var encodedFile: File? = null + return try { val context = getApplication() - val uri = Uri.parse(uriString) - context.contentResolver.openInputStream(uri)?.use { stream -> - val baos = ByteArrayOutputStream() - val b64os = Base64OutputStream(baos, Base64.NO_WRAP) - val buffer = ByteArray(1024 * 128) // 128KB chunk - var bytesRead: Int + val uri = Uri.parse(attachment.uri) + encodedFile = File.createTempFile("chat-attachment-", ".b64", context.cacheDir) + val result = + context.contentResolver.openInputStream(uri)?.use { input -> + encodedFile.outputStream().buffered().use { output -> + encodeAttachmentBase64(input, output) + } + } ?: return PrepareAttachmentResult.Unreadable.also { encodedFile.delete() } - while (stream.read(buffer).also { bytesRead = it } != -1) { - b64os.write(buffer, 0, bytesRead) - yield() // Prevent blocking the thread during large reads + when (result) { + AttachmentSizeResult.WITHIN_LIMIT -> { + PrepareAttachmentResult.Success(PreparedAttachment(attachment, encodedFile)) + } + + AttachmentSizeResult.TOO_LARGE -> { + PrepareAttachmentResult.TooLarge.also { encodedFile.delete() } } - b64os.close() - baos.toString("UTF-8") } } catch (e: Exception) { + encodedFile?.delete() if (e is kotlinx.coroutines.CancellationException) throw e - Log.e(TAG, "Failed to read and encode attachment: ${e.message}", e) - null + Log.e(TAG, "Failed to prepare attachment: ${e.message}", e) + PrepareAttachmentResult.Unreadable } + } + + private fun rejectOversizedAttachment( + attachment: Attachment, + attachments: List, + message: ChatMessage, + wasStreaming: Boolean, + generation: Long, + ) { + Log.w(TAG, "Rejecting oversized attachment: ${attachment.name}") + if (generation != sessionGeneration) return + _uiState.update { state -> + state.copy( + messages = state.messages.filterNot { it.id == message.id }, + pendingAttachments = attachments + state.pendingAttachments, + composerTextToRestore = message.content, + errorMessage = attachmentTooLargeMessage(attachment), + isAgentTyping = wasStreaming, + ) + } + } + + private fun attachmentTooLargeMessage(attachment: Attachment): String = + "Attachment too large: ${attachment.name} (maximum 10 MB)" /** * Send a JSON-RPC call and suspend until the response arrives, delegating @@ -1656,6 +1781,10 @@ class ChatViewModel( _uiState.update { it.copy(openError = null) } } + fun consumeComposerTextRestore() { + _uiState.update { it.copy(composerTextToRestore = null) } + } + fun gatewayPathFor(attachment: Attachment): String = mediaDelegate.gatewayPathFor(attachment) fun clearAttachments() = attachmentsDelegate.clearAttachments() @@ -2794,6 +2923,7 @@ class ChatViewModel( contextBreakdown = null, compressionCount = null, pendingAttachments = emptyList(), + composerTextToRestore = null, reactionKind = null, subagentIndicators = emptyList(), todos = emptyList(), diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/AttachmentBase64EncoderTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/AttachmentBase64EncoderTest.kt new file mode 100644 index 00000000..2d91dbcc --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/AttachmentBase64EncoderTest.kt @@ -0,0 +1,37 @@ +package com.m57.hermescontrol.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream + +class AttachmentBase64EncoderTest { + @Test + fun inputOverLimit_isRejectedWithoutGrowingTheEncodedOutput() { + val output = ByteArrayOutputStream() + val result = + encodeAttachmentBase64( + input = ByteArrayInputStream(byteArrayOf(1, 2, 3, 4, 5)), + output = output, + maxBytes = 4, + ) + + assertEquals(AttachmentSizeResult.TOO_LARGE, result) + assertTrue(output.size() <= 8) + } + + @Test + fun inputAtLimit_isEncodedWithoutLineBreaks() { + val output = ByteArrayOutputStream() + val result = + encodeAttachmentBase64( + input = ByteArrayInputStream(byteArrayOf(1, 2, 3, 4)), + output = output, + maxBytes = 4, + ) + + assertEquals(AttachmentSizeResult.WITHIN_LIMIT, result) + assertEquals("AQIDBA==", output.toString(Charsets.US_ASCII.name())) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatInputPolicyTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatInputPolicyTest.kt index f5aed210..5381e410 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatInputPolicyTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatInputPolicyTest.kt @@ -110,6 +110,18 @@ class ChatInputPolicyTest { assertTrue("cursor must be past the shared /h prefix", value.selection.start > 2) } + @Test + fun restoreRejectedText_preservesNewerDraft() { + val value = + ChatInputPolicy.restoreRejectedText( + rejectedText = "rejected prompt", + current = TextFieldValue("new draft"), + ) + + assertEquals("rejected prompt\nnew draft", value.text) + assertEquals(value.text.length, value.selection.end) + } + // ── Slash suggestion ranking (issue #865) ─────────────────────────────── @Test diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index cf10e240..06c62f83 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -69,6 +69,7 @@ class ChatViewModelTest { private lateinit var app: Application private lateinit var fakeRepo: FakeChatPersistenceRepository private lateinit var fakeSlashUsageStore: FakeSlashUsageStore + private lateinit var attachmentCacheDir: java.io.File /** Counter used to generate unique WS request IDs. */ private var reqCount = 0 @@ -143,6 +144,11 @@ class ChatViewModelTest { mockkObject(HermesDatabase) app = mockk(relaxed = true) + attachmentCacheDir = + java.nio.file.Files + .createTempDirectory("chat-attachments-test") + .toFile() + every { app.cacheDir } returns attachmentCacheDir fakeRepo = FakeChatPersistenceRepository() fakeSlashUsageStore = FakeSlashUsageStore() ActiveSessionHolder.set(null) @@ -207,6 +213,7 @@ class ChatViewModelTest { @After fun tearDown() { Dispatchers.resetMain() + attachmentCacheDir.deleteRecursively() unmockkAll() } @@ -1680,6 +1687,325 @@ class ChatViewModelTest { assertEquals(selected, viewModel.uiState.value.pendingAttachments) } + @Test + fun sendMessage_mixedAttachmentsWithOversizedFileAreRejectedBeforeReading() = + runTest { + val (viewModel, _) = createViewModelWithSession() + val contentResolver = mockk() + every { app.contentResolver } returns contentResolver + + viewModel.addAttachment( + uri = "content://image/photo", + name = "photo.jpg", + mimeType = "image/jpeg", + size = 1024, + ) + viewModel.addAttachment( + uri = "content://oversized/archive", + name = "archive.zip", + mimeType = "application/zip", + size = MAX_CHAT_ATTACHMENT_BYTES + 1, + ) + + viewModel.sendMessage("Inspect this archive") + advanceUntilIdle() + + assertEquals(2, viewModel.uiState.value.pendingAttachments.size) + assertEquals("Inspect this archive", viewModel.uiState.value.composerTextToRestore) + assertTrue( + viewModel.uiState.value.errorMessage + ?.contains("too large") == true, + ) + verify(exactly = 0) { contentResolver.openInputStream(any()) } + + viewModel.consumeComposerTextRestore() + advanceUntilIdle() + assertNull(viewModel.uiState.value.composerTextToRestore) + } + + @Test + fun sendMessage_unknownSizeOversizedAttachmentRestoresComposerWithoutGhostMessage() = + runTest { + val (viewModel, _) = createViewModelWithSession() + val uriString = "content://unknown/large-file" + val mockUri = mockk() + val contentResolver = mockk() + val oversizedStream = + object : java.io.InputStream() { + private var remaining = MAX_CHAT_ATTACHMENT_BYTES + 1 + + override fun read(): Int = if (remaining-- > 0) 0 else -1 + + override fun read( + buffer: ByteArray, + offset: Int, + length: Int, + ): Int { + if (remaining <= 0) return -1 + val count = minOf(length.toLong(), remaining).toInt() + remaining -= count + return count + } + } + mockkStatic(Uri::class) + every { Uri.parse(uriString) } returns mockUri + every { app.contentResolver } returns contentResolver + every { contentResolver.openInputStream(mockUri) } returns oversizedStream + + viewModel.addAttachment( + uri = uriString, + name = "unknown-size.bin", + mimeType = "application/octet-stream", + size = 0, + ) + + viewModel.sendMessage("Inspect this file") + advanceUntilIdle() + + assertEquals(1, viewModel.uiState.value.pendingAttachments.size) + assertEquals("Inspect this file", viewModel.uiState.value.composerTextToRestore) + assertFalse( + viewModel.uiState.value.messages + .any { it.content == "Inspect this file" }, + ) + assertTrue( + viewModel.uiState.value.errorMessage + ?.contains("too large") == true, + ) + } + + @Test + fun sendMessage_oversizedDuringSnapshotLeavesNoPersistedGhostMessage() = + runTest { + val (viewModel, _) = createViewModelWithSession() + val persistedBeforeSend = fakeRepo.dao.count() + val uriString = "content://unknown/growing-file" + val mockUri = mockk() + val contentResolver = mockk() + val oversizedStream = + object : java.io.InputStream() { + private var remaining = MAX_CHAT_ATTACHMENT_BYTES + 1 + + override fun read(): Int = if (remaining-- > 0) 0 else -1 + + override fun read( + buffer: ByteArray, + offset: Int, + length: Int, + ): Int { + if (remaining <= 0) return -1 + val count = minOf(length.toLong(), remaining).toInt() + remaining -= count + return count + } + } + mockkStatic(Uri::class) + every { Uri.parse(uriString) } returns mockUri + every { app.contentResolver } returns contentResolver + every { contentResolver.openInputStream(mockUri) } returns oversizedStream + + viewModel.addAttachment( + uri = uriString, + name = "growing-file.bin", + mimeType = "application/octet-stream", + size = 0, + ) + + viewModel.sendMessage("Inspect changing file") + advanceUntilIdle() + + assertEquals(persistedBeforeSend, fakeRepo.dao.count()) + assertFalse( + fakeRepo.dao + .getMessagesForSession("session-123") + .any { it.content == "Inspect changing file" }, + ) + assertEquals(1, viewModel.uiState.value.pendingAttachments.size) + assertFalse( + viewModel.uiState.value.messages + .any { it.content == "Inspect changing file" }, + ) + } + + @Test + fun sendMessage_persistenceFailureRestoresComposerAndDoesNotDispatch() = + runTest { + fakeRepo = spyk(fakeRepo) + coEvery { + fakeRepo.persistMessage(match { it.content == "keep this prompt" }, any()) + } throws IllegalStateException("disk full") + val (viewModel, _) = createViewModelWithSession() + + viewModel.sendMessage("keep this prompt") + advanceUntilIdle() + + val state = viewModel.uiState.value + assertFalse(state.messages.any { it.content == "keep this prompt" }) + assertEquals("keep this prompt", state.composerTextToRestore) + assertTrue(state.errorMessage?.contains("save") == true) + assertFalse(state.isAgentTyping) + verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) } + } + + @Test + fun sendMessage_laterGrowingAttachmentDoesNotPartiallyUploadEarlierAttachments() = + runTest { + val (viewModel, _) = createViewModelWithSession() + val imageUriString = "content://image/valid" + val growingUriString = "content://unknown/growing-file" + val imageUri = mockk() + val growingUri = mockk() + val contentResolver = mockk() + val oversizedStream = + object : java.io.InputStream() { + private var remaining = MAX_CHAT_ATTACHMENT_BYTES + 1 + + override fun read(): Int = if (remaining-- > 0) 0 else -1 + + override fun read( + buffer: ByteArray, + offset: Int, + length: Int, + ): Int { + if (remaining <= 0) return -1 + val count = minOf(length.toLong(), remaining).toInt() + remaining -= count + return count + } + } + mockkStatic(Uri::class) + every { Uri.parse(imageUriString) } returns imageUri + every { Uri.parse(growingUriString) } returns growingUri + every { app.contentResolver } returns contentResolver + every { contentResolver.openInputStream(imageUri) } returns + java.io.ByteArrayInputStream(byteArrayOf(1, 2, 3, 4)) + every { contentResolver.openInputStream(growingUri) } returns oversizedStream + + viewModel.addAttachments( + listOf( + Attachment(imageUriString, "photo.jpg", "image/jpeg", 4), + Attachment(growingUriString, "growing.bin", "application/octet-stream", 0), + ), + ) + + viewModel.sendMessage("Inspect both") + advanceUntilIdle() + + verify(exactly = 0) { + HermesWsClient.request( + match { it == WsMethods.IMAGE_ATTACH_BYTES || it == WsMethods.FILE_ATTACH }, + any(), + any(), + ) + } + assertEquals(2, viewModel.uiState.value.pendingAttachments.size) + } + + @Test + fun sendMessage_oversizeResultAfterSessionSwitchDoesNotMutateNewSession() = + runTest { + stubSession456Rests(success = true) + val (viewModel, _) = createViewModelWithSession() + val uriString = "content://unknown/session-switch-large-file" + val mockUri = mockk() + val contentResolver = mockk() + val oversizedStream = + object : java.io.InputStream() { + private var remaining = MAX_CHAT_ATTACHMENT_BYTES + 1 + + override fun read(): Int = if (remaining-- > 0) 0 else -1 + + override fun read( + buffer: ByteArray, + offset: Int, + length: Int, + ): Int { + if (remaining <= 0) return -1 + val count = minOf(length.toLong(), remaining).toInt() + java.util.Arrays.fill(buffer, offset, offset + count, 0.toByte()) + remaining -= count + return count + } + } + + mockkStatic(Uri::class) + every { Uri.parse(uriString) } returns mockUri + every { app.contentResolver } returns contentResolver + every { contentResolver.openInputStream(mockUri) } answers { + viewModel.switchSession("session-456") + oversizedStream + } + + viewModel.addAttachment( + uri = uriString, + name = "large.bin", + mimeType = "application/octet-stream", + size = 0, + ) + + viewModel.sendMessage("Inspect in the old session") + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals("session-456", state.currentSessionId) + assertTrue(state.pendingAttachments.isEmpty()) + assertNull(state.composerTextToRestore) + assertNull(state.errorMessage) + } + + @Test + fun sendMessage_promptErrorAfterSessionSwitchDoesNotMutateNewSession() = + runTest { + stubSession456Rests(success = true) + val (viewModel, oldSessionId) = createViewModelWithSession() + val uriString = "content://file/session-switch-valid-file" + val mockUri = mockk() + val contentResolver = mockk() + val promptRequestId = "old-session-prompt" + + mockkStatic(Uri::class) + every { Uri.parse(uriString) } returns mockUri + every { app.contentResolver } returns contentResolver + every { contentResolver.openInputStream(mockUri) } answers { + viewModel.switchSession("session-456") + java.io.ByteArrayInputStream(byteArrayOf(1, 2, 3, 4)) + } + every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers { + arg<((String) -> Unit)?>(2)?.invoke(promptRequestId) + promptRequestId + } + + viewModel.addAttachment( + uri = uriString, + name = "valid.bin", + mimeType = "application/octet-stream", + size = 4, + ) + + viewModel.sendMessage("Send in the old session") + advanceUntilIdle() + + verify { + HermesWsClient.sendMessage( + oldSessionId, + match { it.contains("Send in the old session") }, + any(), + any(), + ) + } + mockEventsFlow.emit( + WsEvent.RpcError( + promptRequestId, + JsonRpcError(code = 4001, message = "old session rejected prompt"), + ), + ) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals("session-456", state.currentSessionId) + assertNull(state.errorMessage) + } + @Test fun testRemoveAttachment_validIndex() = runTest { @@ -3597,6 +3923,7 @@ class ChatViewModelTest { sessionId, paramsSlot.captured["session_id"], ) + assertTrue("encoded attachment cache must be cleaned", attachmentCacheDir.listFiles().isNullOrEmpty()) } // ── Pending request timeout + rejectAllPending (issue #526) ───────────