fix(chat): bound attachment encoding and recover rejected sends
This commit is contained in:
7 files changed
+657
-96
No files matched your search
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,20 @@ object ChatInputPolicy {
|
|||||||
*/
|
*/
|
||||||
fun commandFieldValue(command: String): TextFieldValue = TextFieldValue(command, TextRange(command.length))
|
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
|
* Rank slash-command suggestions by how often the user has dispatched them
|
||||||
* (issue #865): most-used first, ties broken by the current (catalog)
|
* (issue #865): most-used first, ties broken by the current (catalog)
|
||||||
|
|||||||
@@ -298,6 +298,12 @@ fun ChatScreen(
|
|||||||
viewModel.consumePendingPrefill()
|
viewModel.consumePendingPrefill()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(state.composerTextToRestore) {
|
||||||
|
state.composerTextToRestore?.let { text ->
|
||||||
|
inputFieldValue = ChatInputPolicy.restoreRejectedText(text, inputFieldValue)
|
||||||
|
viewModel.consumeComposerTextRestore()
|
||||||
|
}
|
||||||
|
}
|
||||||
var isListening by rememberSaveable { mutableStateOf(false) }
|
var isListening by rememberSaveable { mutableStateOf(false) }
|
||||||
var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) }
|
var lastAnimatedMessageId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var showReloginDialog by rememberSaveable { mutableStateOf(false) }
|
var showReloginDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ package com.m57.hermescontrol.ui.chat
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Base64
|
|
||||||
import android.util.Base64OutputStream
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
@@ -43,7 +41,6 @@ import kotlinx.coroutines.flow.stateIn
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.yield
|
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.decodeFromJsonElement
|
import kotlinx.serialization.json.decodeFromJsonElement
|
||||||
import kotlinx.serialization.json.intOrNull
|
import kotlinx.serialization.json.intOrNull
|
||||||
@@ -52,12 +49,27 @@ import okhttp3.MediaType.Companion.toMediaType
|
|||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.File
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
private const val TAG = "ChatViewModel"
|
private const val TAG = "ChatViewModel"
|
||||||
private const val MESSAGE_PAGE_SIZE = 150
|
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.
|
* Canonical comparison key for a tool message's result payload.
|
||||||
*
|
*
|
||||||
@@ -349,6 +361,8 @@ data class ChatUiState(
|
|||||||
val compressionCount: Int? = null,
|
val compressionCount: Int? = null,
|
||||||
// Attachment state
|
// Attachment state
|
||||||
val pendingAttachments: List<Attachment> = emptyList(),
|
val pendingAttachments: List<Attachment> = 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
|
// Reaction animation — set when a reaction WS event arrives, auto-clears
|
||||||
val reactionKind: String? = null,
|
val reactionKind: String? = null,
|
||||||
/** Monotonic trigger ID so consecutive same-kind reactions re-animate. */
|
/** Monotonic trigger ID so consecutive same-kind reactions re-animate. */
|
||||||
@@ -519,6 +533,7 @@ class ChatViewModel(
|
|||||||
val text: String,
|
val text: String,
|
||||||
val attachments: List<Attachment>,
|
val attachments: List<Attachment>,
|
||||||
val wasStreaming: Boolean,
|
val wasStreaming: Boolean,
|
||||||
|
val userMessage: ChatMessage,
|
||||||
)
|
)
|
||||||
|
|
||||||
private var pendingInitialPrompt: PendingPrompt? = null
|
private var pendingInitialPrompt: PendingPrompt? = null
|
||||||
@@ -1132,6 +1147,7 @@ class ChatViewModel(
|
|||||||
wasStreaming = pending.wasStreaming,
|
wasStreaming = pending.wasStreaming,
|
||||||
storageSessionId = storageId,
|
storageSessionId = storageId,
|
||||||
agentSessionId = runtimeId,
|
agentSessionId = runtimeId,
|
||||||
|
userMessage = pending.userMessage,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1436,6 +1452,20 @@ class ChatViewModel(
|
|||||||
return
|
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
|
// Snapshot + clear attachments so the input bar empties immediately
|
||||||
val attachments = _uiState.value.pendingAttachments.toList()
|
val attachments = _uiState.value.pendingAttachments.toList()
|
||||||
clearAttachments()
|
clearAttachments()
|
||||||
@@ -1463,7 +1493,7 @@ class ChatViewModel(
|
|||||||
if (storageSessionId == null || agentSessionId == null) {
|
if (storageSessionId == null || agentSessionId == null) {
|
||||||
// Issue #969: Session creation is still in-flight. Hold the prompt
|
// Issue #969: Session creation is still in-flight. Hold the prompt
|
||||||
// so it is dispatched automatically the moment SESSION_CREATE lands.
|
// so it is dispatched automatically the moment SESSION_CREATE lands.
|
||||||
pendingInitialPrompt = PendingPrompt(text, attachments, wasStreaming)
|
pendingInitialPrompt = PendingPrompt(text, attachments, wasStreaming, userMessage)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1485,6 +1515,7 @@ class ChatViewModel(
|
|||||||
agentSessionId: String,
|
agentSessionId: String,
|
||||||
userMessage: ChatMessage? = null,
|
userMessage: ChatMessage? = null,
|
||||||
) {
|
) {
|
||||||
|
val dispatchGeneration = sessionGeneration
|
||||||
val msgToPersist =
|
val msgToPersist =
|
||||||
userMessage ?: ChatMessage(
|
userMessage ?: ChatMessage(
|
||||||
role = MessageRole.USER,
|
role = MessageRole.USER,
|
||||||
@@ -1492,128 +1523,222 @@ class ChatViewModel(
|
|||||||
attachments = if (attachments.isNotEmpty()) attachments else null,
|
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
|
// Upload attachments then submit prompt
|
||||||
viewModelScope.launch(ioDispatcher) {
|
viewModelScope.launch(ioDispatcher) {
|
||||||
val fileRefs = mutableListOf<String>()
|
val fileRefs = mutableListOf<String>()
|
||||||
|
val preparedAttachments = mutableListOf<PreparedAttachment>()
|
||||||
|
|
||||||
for (attachment in attachments) {
|
try {
|
||||||
val b64 = readContentUriBase64(attachment.uri)
|
// Snapshot every attachment before the first RPC. This closes the
|
||||||
if (b64 == null) {
|
// content-URI TOCTOU window without retaining multiple Base64
|
||||||
Log.w(TAG, "Skipping unreadable attachment: ${attachment.name}")
|
// strings in the heap: encoded snapshots live in private cache.
|
||||||
continue
|
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 {
|
try {
|
||||||
if (attachment.isImage) {
|
repo.persistMessage(msgToPersist, storageSessionId)
|
||||||
// Await so the backend stages the image into
|
} catch (e: Exception) {
|
||||||
// session["attached_images"] BEFORE prompt.submit runs
|
if (e is kotlinx.coroutines.CancellationException) throw e
|
||||||
// (a fire-and-forget send raced prompt.submit and the
|
Log.e(TAG, "Failed to persist outgoing message", e)
|
||||||
// image was dropped). Requires session_id or the gateway
|
if (dispatchGeneration == sessionGeneration) {
|
||||||
// 4001s "session not found" (desktop passes it too).
|
_uiState.update { state ->
|
||||||
val result =
|
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<String, Any?>)?.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(
|
sendRpcAndAwait(
|
||||||
method = WsMethods.IMAGE_ATTACH_BYTES,
|
method = WsMethods.FILE_ATTACH,
|
||||||
params =
|
params =
|
||||||
mapOf(
|
mapOf(
|
||||||
"session_id" to agentSessionId,
|
"session_id" to agentSessionId,
|
||||||
"content_base64" to "data:${attachment.mimeType};base64,$b64",
|
"data_url" to "data:${attachment.mimeType};base64,$b64",
|
||||||
"filename" to attachment.name,
|
"name" to attachment.name,
|
||||||
"ext" to attachment.fileExtension,
|
|
||||||
),
|
),
|
||||||
)
|
)?.let { result ->
|
||||||
if (result != null) {
|
@Suppress("UNCHECKED_CAST")
|
||||||
@Suppress("UNCHECKED_CAST")
|
val refText =
|
||||||
val ok = (result as? Map<String, Any?>)?.get("attached") as? Boolean
|
(result as? Map<String, Any?>)?.get("ref_text") as? String
|
||||||
if (ok != true) {
|
if (!refText.isNullOrBlank()) fileRefs.add(refText)
|
||||||
Log.w(TAG, "Image attach for ${attachment.name} returned non-ok: $result")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} catch (e: Exception) {
|
||||||
// Await the @file: ref text so we can embed it in the prompt.
|
if (e is kotlinx.coroutines.CancellationException) throw e
|
||||||
// file.attach also requires session_id or the gateway 4001s
|
Log.e(TAG, "Failed to upload attachment ${attachment.name}", e)
|
||||||
// "session not found" (same resolver as image.attach_bytes).
|
if (dispatchGeneration == sessionGeneration) {
|
||||||
sendRpcAndAwait(
|
_uiState.update {
|
||||||
method = WsMethods.FILE_ATTACH,
|
it.copy(errorMessage = "Upload failed: ${attachment.name}")
|
||||||
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<String, Any?>)?.get("ref_text") as? String
|
|
||||||
if (!refText.isNullOrBlank()) fileRefs.add(refText)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
}
|
||||||
Log.e(TAG, "Failed to upload attachment ${attachment.name}", e)
|
|
||||||
_uiState.update {
|
// Build prompt text — prepend @file: refs for non-image files
|
||||||
it.copy(errorMessage = "Upload failed: ${attachment.name}")
|
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
|
// While a turn is actively streaming and this is a plain text prompt
|
||||||
val fullText =
|
// (no attachments — session.redirect carries text only), steer the
|
||||||
if (fileRefs.isEmpty()) {
|
// in-flight turn via session.redirect instead of queueing a fresh
|
||||||
text
|
// 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 {
|
} else {
|
||||||
fileRefs.joinToString("\n") +
|
wsClient.sendMessage(
|
||||||
if (text.isNotBlank()) "\n\n$text" else ""
|
agentSessionId,
|
||||||
|
fullText,
|
||||||
|
onSent = { id ->
|
||||||
|
trackSessionRequest(
|
||||||
|
id = id,
|
||||||
|
method = WsMethods.PROMPT_SUBMIT,
|
||||||
|
generation = dispatchGeneration,
|
||||||
|
sessionId = storageSessionId,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
// While a turn is actively streaming and this is a plain text prompt
|
preparedAttachments.forEach { it.encodedFile.delete() }
|
||||||
// (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) },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read and encode a `content://` or `file://` URI to Base64 via ContentResolver, avoiding large allocations. */
|
/** Snapshot one URI to private cache while enforcing the outbound frame limit. */
|
||||||
private suspend fun readContentUriBase64(uriString: String): String? =
|
private suspend fun prepareAttachment(attachment: Attachment): PrepareAttachmentResult {
|
||||||
try {
|
var encodedFile: File? = null
|
||||||
|
return try {
|
||||||
val context = getApplication<Application>()
|
val context = getApplication<Application>()
|
||||||
val uri = Uri.parse(uriString)
|
val uri = Uri.parse(attachment.uri)
|
||||||
context.contentResolver.openInputStream(uri)?.use { stream ->
|
encodedFile = File.createTempFile("chat-attachment-", ".b64", context.cacheDir)
|
||||||
val baos = ByteArrayOutputStream()
|
val result =
|
||||||
val b64os = Base64OutputStream(baos, Base64.NO_WRAP)
|
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||||
val buffer = ByteArray(1024 * 128) // 128KB chunk
|
encodedFile.outputStream().buffered().use { output ->
|
||||||
var bytesRead: Int
|
encodeAttachmentBase64(input, output)
|
||||||
|
}
|
||||||
|
} ?: return PrepareAttachmentResult.Unreadable.also { encodedFile.delete() }
|
||||||
|
|
||||||
while (stream.read(buffer).also { bytesRead = it } != -1) {
|
when (result) {
|
||||||
b64os.write(buffer, 0, bytesRead)
|
AttachmentSizeResult.WITHIN_LIMIT -> {
|
||||||
yield() // Prevent blocking the thread during large reads
|
PrepareAttachmentResult.Success(PreparedAttachment(attachment, encodedFile))
|
||||||
|
}
|
||||||
|
|
||||||
|
AttachmentSizeResult.TOO_LARGE -> {
|
||||||
|
PrepareAttachmentResult.TooLarge.also { encodedFile.delete() }
|
||||||
}
|
}
|
||||||
b64os.close()
|
|
||||||
baos.toString("UTF-8")
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
encodedFile?.delete()
|
||||||
if (e is kotlinx.coroutines.CancellationException) throw e
|
if (e is kotlinx.coroutines.CancellationException) throw e
|
||||||
Log.e(TAG, "Failed to read and encode attachment: ${e.message}", e)
|
Log.e(TAG, "Failed to prepare attachment: ${e.message}", e)
|
||||||
null
|
PrepareAttachmentResult.Unreadable
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rejectOversizedAttachment(
|
||||||
|
attachment: Attachment,
|
||||||
|
attachments: List<Attachment>,
|
||||||
|
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
|
* Send a JSON-RPC call and suspend until the response arrives, delegating
|
||||||
@@ -1656,6 +1781,10 @@ class ChatViewModel(
|
|||||||
_uiState.update { it.copy(openError = null) }
|
_uiState.update { it.copy(openError = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun consumeComposerTextRestore() {
|
||||||
|
_uiState.update { it.copy(composerTextToRestore = null) }
|
||||||
|
}
|
||||||
|
|
||||||
fun gatewayPathFor(attachment: Attachment): String = mediaDelegate.gatewayPathFor(attachment)
|
fun gatewayPathFor(attachment: Attachment): String = mediaDelegate.gatewayPathFor(attachment)
|
||||||
|
|
||||||
fun clearAttachments() = attachmentsDelegate.clearAttachments()
|
fun clearAttachments() = attachmentsDelegate.clearAttachments()
|
||||||
@@ -2794,6 +2923,7 @@ class ChatViewModel(
|
|||||||
contextBreakdown = null,
|
contextBreakdown = null,
|
||||||
compressionCount = null,
|
compressionCount = null,
|
||||||
pendingAttachments = emptyList(),
|
pendingAttachments = emptyList(),
|
||||||
|
composerTextToRestore = null,
|
||||||
reactionKind = null,
|
reactionKind = null,
|
||||||
subagentIndicators = emptyList(),
|
subagentIndicators = emptyList(),
|
||||||
todos = emptyList(),
|
todos = emptyList(),
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,6 +110,18 @@ class ChatInputPolicyTest {
|
|||||||
assertTrue("cursor must be past the shared /h prefix", value.selection.start > 2)
|
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) ───────────────────────────────
|
// ── Slash suggestion ranking (issue #865) ───────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ class ChatViewModelTest {
|
|||||||
private lateinit var app: Application
|
private lateinit var app: Application
|
||||||
private lateinit var fakeRepo: FakeChatPersistenceRepository
|
private lateinit var fakeRepo: FakeChatPersistenceRepository
|
||||||
private lateinit var fakeSlashUsageStore: FakeSlashUsageStore
|
private lateinit var fakeSlashUsageStore: FakeSlashUsageStore
|
||||||
|
private lateinit var attachmentCacheDir: java.io.File
|
||||||
|
|
||||||
/** Counter used to generate unique WS request IDs. */
|
/** Counter used to generate unique WS request IDs. */
|
||||||
private var reqCount = 0
|
private var reqCount = 0
|
||||||
@@ -143,6 +144,11 @@ class ChatViewModelTest {
|
|||||||
mockkObject(HermesDatabase)
|
mockkObject(HermesDatabase)
|
||||||
|
|
||||||
app = mockk(relaxed = true)
|
app = mockk(relaxed = true)
|
||||||
|
attachmentCacheDir =
|
||||||
|
java.nio.file.Files
|
||||||
|
.createTempDirectory("chat-attachments-test")
|
||||||
|
.toFile()
|
||||||
|
every { app.cacheDir } returns attachmentCacheDir
|
||||||
fakeRepo = FakeChatPersistenceRepository()
|
fakeRepo = FakeChatPersistenceRepository()
|
||||||
fakeSlashUsageStore = FakeSlashUsageStore()
|
fakeSlashUsageStore = FakeSlashUsageStore()
|
||||||
ActiveSessionHolder.set(null)
|
ActiveSessionHolder.set(null)
|
||||||
@@ -207,6 +213,7 @@ class ChatViewModelTest {
|
|||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
Dispatchers.resetMain()
|
Dispatchers.resetMain()
|
||||||
|
attachmentCacheDir.deleteRecursively()
|
||||||
unmockkAll()
|
unmockkAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1680,6 +1687,325 @@ class ChatViewModelTest {
|
|||||||
assertEquals(selected, viewModel.uiState.value.pendingAttachments)
|
assertEquals(selected, viewModel.uiState.value.pendingAttachments)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sendMessage_mixedAttachmentsWithOversizedFileAreRejectedBeforeReading() =
|
||||||
|
runTest {
|
||||||
|
val (viewModel, _) = createViewModelWithSession()
|
||||||
|
val contentResolver = mockk<ContentResolver>()
|
||||||
|
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<Uri>()
|
||||||
|
val contentResolver = mockk<ContentResolver>()
|
||||||
|
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<Uri>()
|
||||||
|
val contentResolver = mockk<ContentResolver>()
|
||||||
|
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<Uri>()
|
||||||
|
val growingUri = mockk<Uri>()
|
||||||
|
val contentResolver = mockk<ContentResolver>()
|
||||||
|
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<Uri>()
|
||||||
|
val contentResolver = mockk<ContentResolver>()
|
||||||
|
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<Uri>()
|
||||||
|
val contentResolver = mockk<ContentResolver>()
|
||||||
|
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
|
@Test
|
||||||
fun testRemoveAttachment_validIndex() =
|
fun testRemoveAttachment_validIndex() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -3597,6 +3923,7 @@ class ChatViewModelTest {
|
|||||||
sessionId,
|
sessionId,
|
||||||
paramsSlot.captured["session_id"],
|
paramsSlot.captured["session_id"],
|
||||||
)
|
)
|
||||||
|
assertTrue("encoded attachment cache must be cleaned", attachmentCacheDir.listFiles().isNullOrEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Pending request timeout + rejectAllPending (issue #526) ───────────
|
// ── Pending request timeout + rejectAllPending (issue #526) ───────────
|
||||||
|
|||||||
Reference in new issue
Block a user