chore: promote dev to main (#1455)
Co-authored-by: AlexDev_ <56083016+alexdev03@users.noreply.github.com>
This commit is contained in:
1 parent
62fcf07e1b
commit
944390bec1
56 files changed
+3874
-264
No files matched your search
+257
@@ -0,0 +1,257 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipDescription
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.net.Uri
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
|
import android.view.inputmethod.InputConnection
|
||||||
|
import android.view.inputmethod.InputContentInfo
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.platform.InterceptPlatformTextInput
|
||||||
|
import androidx.compose.ui.semantics.SemanticsActions
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performSemanticsAction
|
||||||
|
import androidx.compose.ui.text.TextRange
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
|
import com.m57.hermescontrol.data.ws.CommandCatalog
|
||||||
|
import com.m57.hermescontrol.ui.chat.ChatAttachmentTarget
|
||||||
|
import com.m57.hermescontrol.ui.chat.ChatPastedImageStore
|
||||||
|
import kotlinx.coroutines.awaitCancellation
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/** Real BasicTextField paste and InputConnection entry points; no direct listener invocation. */
|
||||||
|
@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class)
|
||||||
|
class ComposerImagePasteTest {
|
||||||
|
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
private val attachments = mutableStateListOf<Attachment>()
|
||||||
|
private val errors = mutableListOf<Int>()
|
||||||
|
private val ownedFiles = mutableListOf<File>()
|
||||||
|
private val input = TextFieldState("keep draft", TextRange(1, 3))
|
||||||
|
private var sends = 0
|
||||||
|
private var connection: InputConnection? = null
|
||||||
|
private var editorInfo: EditorInfo? = null
|
||||||
|
private lateinit var controller: ChatImagePasteController
|
||||||
|
|
||||||
|
private fun prepareComposer(interceptIme: Boolean = false) {
|
||||||
|
compose.setContent {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
controller =
|
||||||
|
remember {
|
||||||
|
ChatImagePasteController(
|
||||||
|
scope = scope,
|
||||||
|
importImage = ChatPastedImageStore(context)::importImage,
|
||||||
|
captureTarget = { ChatAttachmentTarget("test", 1, "http://test.local", null, null) },
|
||||||
|
commit = { _, images ->
|
||||||
|
attachments.addAll(images)
|
||||||
|
true
|
||||||
|
},
|
||||||
|
onError = errors::add,
|
||||||
|
isImageUri = { uri ->
|
||||||
|
context.contentResolver.getType(uri)?.startsWith("image/", ignoreCase = true) == true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
InterceptPlatformTextInput(interceptor = { request, next ->
|
||||||
|
if (interceptIme) {
|
||||||
|
val info = EditorInfo()
|
||||||
|
connection = request.createInputConnection(info)
|
||||||
|
editorInfo = info
|
||||||
|
awaitCancellation()
|
||||||
|
} else {
|
||||||
|
next.startInputMethod(request)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
ChatInputBar(
|
||||||
|
inputState = input,
|
||||||
|
onSend = { sends++ },
|
||||||
|
onMicTap = {},
|
||||||
|
isListening = false,
|
||||||
|
isAgentTyping = false,
|
||||||
|
isConnected = true,
|
||||||
|
isSessionReady = true,
|
||||||
|
commandCatalog = CommandCatalog(),
|
||||||
|
pendingAttachments = attachments,
|
||||||
|
receiveContentListener = controller,
|
||||||
|
isReceivingContent = controller.isReceiving,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.onNodeWithTag("chat_input").performClick()
|
||||||
|
compose.runOnIdle { input.edit { selection = TextRange(1, 3) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun imageUri(): Uri {
|
||||||
|
val file = File(context.cacheDir, "clipboard-test-${UUID.randomUUID()}.png")
|
||||||
|
ownedFiles.add(file)
|
||||||
|
val bitmap = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888)
|
||||||
|
try {
|
||||||
|
file.outputStream().use { assertTrue(bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)) }
|
||||||
|
} finally {
|
||||||
|
bitmap.recycle()
|
||||||
|
}
|
||||||
|
return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pdfUri(): Uri {
|
||||||
|
val file = File(context.cacheDir, "clipboard-test-${UUID.randomUUID()}.pdf")
|
||||||
|
ownedFiles.add(file)
|
||||||
|
file.writeText("%PDF-1.4\nfixture\n")
|
||||||
|
return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun paste(clip: ClipData) {
|
||||||
|
compose.runOnIdle {
|
||||||
|
context.getSystemService(ClipboardManager::class.java).setPrimaryClip(clip)
|
||||||
|
}
|
||||||
|
compose.onNodeWithTag("chat_input").performSemanticsAction(SemanticsActions.PasteText) { it() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertImported(source: Uri) {
|
||||||
|
compose.waitUntil(timeoutMillis = 10_000) { attachments.size == 1 && !controller.isReceiving }
|
||||||
|
compose.runOnIdle {
|
||||||
|
assertEquals("keep draft", input.text.toString())
|
||||||
|
assertEquals(TextRange(1, 3), input.selection)
|
||||||
|
assertEquals(0, sends)
|
||||||
|
assertTrue(errors.isEmpty())
|
||||||
|
assertNotEquals(source.toString(), attachments.single().uri)
|
||||||
|
val copy = File(context.cacheDir, "pasted_images/${attachments.single().name}")
|
||||||
|
assertTrue(copy.isFile)
|
||||||
|
assertEquals(copy.length(), attachments.single().size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clipboardImage_becomesPrivateAttachmentWithoutReplacingSelectionOrSending() {
|
||||||
|
prepareComposer()
|
||||||
|
val source = imageUri()
|
||||||
|
paste(ClipData.newUri(context.contentResolver, "test image", source))
|
||||||
|
assertImported(source)
|
||||||
|
// Clipboard replacement cannot revoke access to the independently owned chip.
|
||||||
|
compose.runOnIdle {
|
||||||
|
context
|
||||||
|
.getSystemService(ClipboardManager::class.java)
|
||||||
|
.setPrimaryClip(ClipData.newPlainText("replacement", "different"))
|
||||||
|
assertTrue(File(context.cacheDir, "pasted_images/${attachments.single().name}").isFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun imeCommitContent_usesRealInputConnectionAndPreservesDraft() {
|
||||||
|
prepareComposer(interceptIme = true)
|
||||||
|
compose.waitUntil(timeoutMillis = 5_000) { connection != null }
|
||||||
|
val source = imageUri()
|
||||||
|
compose.runOnIdle {
|
||||||
|
assertTrue(
|
||||||
|
editorInfo!!.contentMimeTypes.orEmpty().any { ClipDescription.compareMimeTypes("image/png", it) },
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
connection!!.commitContent(
|
||||||
|
InputContentInfo(source, ClipDescription("image", arrayOf("image/png")), null),
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertImported(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mixedClipboard_keepsTextItemsOnTheNormalPastePath() {
|
||||||
|
prepareComposer()
|
||||||
|
val source = imageUri()
|
||||||
|
val clip = ClipData(ClipDescription("mixed", arrayOf("image/png", "text/plain")), ClipData.Item("hello"))
|
||||||
|
clip.addItem(ClipData.Item(source))
|
||||||
|
paste(clip)
|
||||||
|
compose.waitUntil(timeoutMillis = 10_000) { attachments.size == 1 && !controller.isReceiving }
|
||||||
|
compose.runOnIdle {
|
||||||
|
assertEquals("khellop draft", input.text.toString())
|
||||||
|
assertFalse(input.text.contains("content://"))
|
||||||
|
assertEquals(0, sends)
|
||||||
|
assertTrue(errors.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dualRepresentationItem_preservesTextPasteAndImportsImage() {
|
||||||
|
prepareComposer()
|
||||||
|
val source = imageUri()
|
||||||
|
val clip =
|
||||||
|
ClipData(
|
||||||
|
ClipDescription("dual", arrayOf("image/png", "text/plain")),
|
||||||
|
ClipData.Item("hello", null, null, source),
|
||||||
|
)
|
||||||
|
paste(clip)
|
||||||
|
compose.waitUntil(timeoutMillis = 10_000) { attachments.size == 1 && !controller.isReceiving }
|
||||||
|
compose.runOnIdle {
|
||||||
|
assertEquals("khellop draft", input.text.toString())
|
||||||
|
assertEquals("image/png", attachments.single().mimeType)
|
||||||
|
assertNotEquals(source.toString(), attachments.single().uri)
|
||||||
|
assertTrue(File(context.cacheDir, "pasted_images/${attachments.single().name}").isFile)
|
||||||
|
assertTrue(errors.isEmpty())
|
||||||
|
assertEquals(0, sends)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mixedClipboard_importsImageDespitePdfAndPreservesText() {
|
||||||
|
prepareComposer()
|
||||||
|
val image = imageUri()
|
||||||
|
val pdf = pdfUri()
|
||||||
|
val clip =
|
||||||
|
ClipData(
|
||||||
|
ClipDescription("mixed", arrayOf("image/png", "application/pdf", "text/plain")),
|
||||||
|
ClipData.Item("hello"),
|
||||||
|
)
|
||||||
|
clip.addItem(ClipData.Item(image))
|
||||||
|
clip.addItem(ClipData.Item(pdf))
|
||||||
|
paste(clip)
|
||||||
|
compose.waitUntil(timeoutMillis = 10_000) { attachments.size == 1 && !controller.isReceiving }
|
||||||
|
compose.runOnIdle {
|
||||||
|
assertEquals("khellop draft", input.text.toString())
|
||||||
|
assertEquals("image/png", attachments.single().mimeType)
|
||||||
|
assertNotEquals(image.toString(), attachments.single().uri)
|
||||||
|
assertTrue(File(context.cacheDir, "pasted_images/${attachments.single().name}").isFile)
|
||||||
|
assertTrue(errors.isEmpty())
|
||||||
|
assertEquals(0, sends)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun plainTextPaste_stillReplacesOnlyTheSelection() {
|
||||||
|
prepareComposer()
|
||||||
|
paste(ClipData.newPlainText("text", "hello"))
|
||||||
|
compose.runOnIdle {
|
||||||
|
assertEquals("khellop draft", input.text.toString())
|
||||||
|
assertTrue(attachments.isEmpty())
|
||||||
|
assertEquals(0, sends)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun cleanupOnlyTestOwnedFiles() {
|
||||||
|
if (::controller.isInitialized) compose.runOnIdle { controller.close() }
|
||||||
|
ownedFiles.forEach { it.delete() }
|
||||||
|
attachments.forEach { File(context.cacheDir, "pasted_images/${it.name}").delete() }
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-5
@@ -1,8 +1,11 @@
|
|||||||
package com.m57.hermescontrol.ui.chat.components
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.text.input.clearText
|
||||||
|
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -23,7 +26,6 @@ import androidx.compose.ui.test.performClick
|
|||||||
import androidx.compose.ui.test.performTextInput
|
import androidx.compose.ui.test.performTextInput
|
||||||
import androidx.compose.ui.test.performTouchInput
|
import androidx.compose.ui.test.performTouchInput
|
||||||
import androidx.compose.ui.test.swipeLeft
|
import androidx.compose.ui.test.swipeLeft
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -58,6 +60,7 @@ class ComposerInteractionTest {
|
|||||||
private var fileTaps = 0
|
private var fileTaps = 0
|
||||||
|
|
||||||
/** Renders the real input bar with live text and a mic that toggles like ChatMediaLaunchers. */
|
/** Renders the real input bar with live text and a mic that toggles like ChatMediaLaunchers. */
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
private fun setComposer(
|
private fun setComposer(
|
||||||
reasoningLevel: String? = "medium",
|
reasoningLevel: String? = "medium",
|
||||||
model: String = "openai/gpt-5.5",
|
model: String = "openai/gpt-5.5",
|
||||||
@@ -67,18 +70,17 @@ class ComposerInteractionTest {
|
|||||||
showModelProvider: Boolean = false,
|
showModelProvider: Boolean = false,
|
||||||
) {
|
) {
|
||||||
composeTestRule.setContent {
|
composeTestRule.setContent {
|
||||||
var input by remember { mutableStateOf(TextFieldValue("")) }
|
val input = rememberTextFieldState()
|
||||||
var listening by remember { mutableStateOf(false) }
|
var listening by remember { mutableStateOf(false) }
|
||||||
var currentModel by remember { mutableStateOf(model) }
|
var currentModel by remember { mutableStateOf(model) }
|
||||||
|
|
||||||
CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) {
|
CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) {
|
||||||
val composer: @Composable () -> Unit = {
|
val composer: @Composable () -> Unit = {
|
||||||
ChatInputBar(
|
ChatInputBar(
|
||||||
inputFieldValue = input,
|
inputState = input,
|
||||||
onInputChange = { input = it },
|
|
||||||
onSend = {
|
onSend = {
|
||||||
sends++
|
sends++
|
||||||
input = TextFieldValue("")
|
input.clearText()
|
||||||
},
|
},
|
||||||
onMicTap = {
|
onMicTap = {
|
||||||
micTaps++
|
micTaps++
|
||||||
|
|||||||
+7
-10
@@ -1,9 +1,8 @@
|
|||||||
package com.m57.hermescontrol.ui.chat.components
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.test.assertIsEnabled
|
import androidx.compose.ui.test.assertIsEnabled
|
||||||
import androidx.compose.ui.test.assertIsNotEnabled
|
import androidx.compose.ui.test.assertIsNotEnabled
|
||||||
import androidx.compose.ui.test.assertTextEquals
|
import androidx.compose.ui.test.assertTextEquals
|
||||||
@@ -11,12 +10,12 @@ import androidx.compose.ui.test.junit4.createComposeRule
|
|||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.compose.ui.test.performTextInput
|
import androidx.compose.ui.test.performTextInput
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
|
||||||
import com.m57.hermescontrol.data.ws.CommandCatalog
|
import com.m57.hermescontrol.data.ws.CommandCatalog
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
class ComposerReadinessTest {
|
class ComposerReadinessTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val compose = createComposeRule()
|
val compose = createComposeRule()
|
||||||
@@ -26,10 +25,9 @@ class ComposerReadinessTest {
|
|||||||
val ready = mutableStateOf(false)
|
val ready = mutableStateOf(false)
|
||||||
var sends = 0
|
var sends = 0
|
||||||
compose.setContent {
|
compose.setContent {
|
||||||
var input by remember { mutableStateOf(TextFieldValue("")) }
|
val input = rememberTextFieldState()
|
||||||
ChatInputBar(
|
ChatInputBar(
|
||||||
inputFieldValue = input,
|
inputState = input,
|
||||||
onInputChange = { input = it },
|
|
||||||
onSend = { sends++ },
|
onSend = { sends++ },
|
||||||
onMicTap = {},
|
onMicTap = {},
|
||||||
isListening = false,
|
isListening = false,
|
||||||
@@ -64,10 +62,9 @@ class ComposerReadinessTest {
|
|||||||
@Test
|
@Test
|
||||||
fun emptyDraftWhileStreamingMakesStopThePrimaryAction() {
|
fun emptyDraftWhileStreamingMakesStopThePrimaryAction() {
|
||||||
compose.setContent {
|
compose.setContent {
|
||||||
var input by remember { mutableStateOf(TextFieldValue("")) }
|
val input = rememberTextFieldState()
|
||||||
ChatInputBar(
|
ChatInputBar(
|
||||||
inputFieldValue = input,
|
inputState = input,
|
||||||
onInputChange = { input = it },
|
|
||||||
onSend = {},
|
onSend = {},
|
||||||
onMicTap = {},
|
onMicTap = {},
|
||||||
isListening = false,
|
isListening = false,
|
||||||
|
|||||||
+1
-1
@@ -130,7 +130,7 @@ class PendingSendRecoveryTest {
|
|||||||
.boundsInRoot.height > 0
|
.boundsInRoot.height > 0
|
||||||
}
|
}
|
||||||
compose.onNodeWithContentDescription("photo.png").assertIsDisplayed()
|
compose.onNodeWithContentDescription("photo.png").assertIsDisplayed()
|
||||||
compose.onNodeWithText("Accepted by server").assertIsDisplayed()
|
compose.onNodeWithContentDescription("Accepted by server").assertIsDisplayed()
|
||||||
compose.onNodeWithTag("pending_send_recovery").assertDoesNotExist()
|
compose.onNodeWithTag("pending_send_recovery").assertDoesNotExist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -3,9 +3,11 @@ package com.m57.hermescontrol.ui.chat.components
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -19,6 +21,7 @@ import androidx.compose.ui.test.SemanticsMatcher
|
|||||||
import androidx.compose.ui.test.assert
|
import androidx.compose.ui.test.assert
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.assertIsFocused
|
import androidx.compose.ui.test.assertIsFocused
|
||||||
|
import androidx.compose.ui.test.assertTextEquals
|
||||||
import androidx.compose.ui.test.captureToImage
|
import androidx.compose.ui.test.captureToImage
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
@@ -26,7 +29,6 @@ import androidx.compose.ui.test.onNodeWithText
|
|||||||
import androidx.compose.ui.test.onRoot
|
import androidx.compose.ui.test.onRoot
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.compose.ui.test.performTouchInput
|
import androidx.compose.ui.test.performTouchInput
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.filters.MediumTest
|
import androidx.test.filters.MediumTest
|
||||||
import com.m57.hermescontrol.data.ws.CommandCatalog
|
import com.m57.hermescontrol.data.ws.CommandCatalog
|
||||||
@@ -52,6 +54,7 @@ import java.io.File
|
|||||||
*/
|
*/
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
@MediumTest
|
@MediumTest
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
class VoiceNoteGestureTest {
|
class VoiceNoteGestureTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
@@ -88,8 +91,7 @@ class VoiceNoteGestureTest {
|
|||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
ChatInputBar(
|
ChatInputBar(
|
||||||
inputFieldValue = TextFieldValue(draft),
|
inputState = rememberTextFieldState(initialText = draft),
|
||||||
onInputChange = {},
|
|
||||||
onSend = { sendCount++ },
|
onSend = { sendCount++ },
|
||||||
onMicTap = { micTaps++ },
|
onMicTap = { micTaps++ },
|
||||||
isListening = false,
|
isListening = false,
|
||||||
@@ -275,6 +277,8 @@ class VoiceNoteGestureTest {
|
|||||||
assertEquals("release must submit the note", 1, holdEnds)
|
assertEquals("release must submit the note", 1, holdEnds)
|
||||||
assertEquals("a stationary hold-release must not fire a tap", tapsBefore, micTaps)
|
assertEquals("a stationary hold-release must not fire a tap", tapsBefore, micTaps)
|
||||||
}
|
}
|
||||||
|
// The state-based input must retain the draft when the recording strip is removed.
|
||||||
|
composeTestRule.onNodeWithTag("chat_input").assertTextEquals("draft")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import com.m57.hermescontrol.data.remote.NetworkMonitor
|
|||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
import com.m57.hermescontrol.notification.TurnCorrelationTracker
|
import com.m57.hermescontrol.notification.TurnCorrelationTracker
|
||||||
import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader
|
import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader
|
||||||
|
import com.m57.hermescontrol.ui.chat.cleanStalePastedImages
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -28,6 +29,10 @@ class HermesControlApp :
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
val startupTimeMs = System.currentTimeMillis()
|
||||||
|
appScope.launch(Dispatchers.IO) {
|
||||||
|
cleanStalePastedImages(cacheDir, startupTimeMs)
|
||||||
|
}
|
||||||
AuthManager.init(this)
|
AuthManager.init(this)
|
||||||
NetworkMonitor.init(this)
|
NetworkMonitor.init(this)
|
||||||
SessionListCacheStore.init(this)
|
SessionListCacheStore.init(this)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.m57.hermescontrol.ui.chat.ChatMessage
|
|||||||
import com.m57.hermescontrol.ui.chat.MessageProvenance
|
import com.m57.hermescontrol.ui.chat.MessageProvenance
|
||||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||||
import com.m57.hermescontrol.ui.chat.ToolStatus
|
import com.m57.hermescontrol.ui.chat.ToolStatus
|
||||||
|
import com.m57.hermescontrol.ui.chat.normalizedGatewayNotice
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts between the Room [ChatMessageEntity] and the UI [ChatMessage].
|
* Converts between the Room [ChatMessageEntity] and the UI [ChatMessage].
|
||||||
@@ -44,7 +45,7 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
|
|||||||
messageProvenance =
|
messageProvenance =
|
||||||
MessageProvenance.entries.firstOrNull { it.name == messageProvenance }
|
MessageProvenance.entries.firstOrNull { it.name == messageProvenance }
|
||||||
?: MessageProvenance.UNKNOWN,
|
?: MessageProvenance.UNKNOWN,
|
||||||
)
|
).normalizedGatewayNotice()
|
||||||
|
|
||||||
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
||||||
ChatMessageEntity(
|
ChatMessageEntity(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.data.model
|
|||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.doubleOrNull
|
import kotlinx.serialization.json.doubleOrNull
|
||||||
import kotlinx.serialization.json.intOrNull
|
import kotlinx.serialization.json.intOrNull
|
||||||
@@ -100,20 +101,10 @@ data class SessionMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val contentText: String
|
val contentText: String
|
||||||
get() =
|
get() = content?.transcriptText().orEmpty()
|
||||||
when (content) {
|
|
||||||
is JsonPrimitive -> content.content
|
|
||||||
null -> ""
|
|
||||||
else -> content.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
val displayContentText: String?
|
val displayContentText: String?
|
||||||
get() =
|
get() = display_content?.transcriptText()
|
||||||
when (display_content) {
|
|
||||||
is JsonPrimitive -> display_content.content
|
|
||||||
null -> null
|
|
||||||
else -> display_content.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
val reasoningText: String
|
val reasoningText: String
|
||||||
get() =
|
get() =
|
||||||
@@ -160,4 +151,25 @@ data class SessionMessage(
|
|||||||
get() = tool_call_id.orEmpty()
|
get() = tool_call_id.orEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #1432: model-facing multipart payloads contain inline image bytes, not displayable JSON.
|
||||||
|
private fun JsonElement.transcriptText(): String =
|
||||||
|
when (this) {
|
||||||
|
is JsonPrimitive -> {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
|
||||||
|
is JsonArray -> {
|
||||||
|
mapNotNull { part ->
|
||||||
|
val block = part as? JsonObject ?: return@mapNotNull null
|
||||||
|
val type = block["type"]
|
||||||
|
if (type != null && (type as? JsonPrimitive)?.content != "text") return@mapNotNull null
|
||||||
|
(block["text"] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||||
|
}.joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val WHITESPACE = Regex("\\s+")
|
private val WHITESPACE = Regex("\\s+")
|
||||||
@@ -279,7 +279,7 @@ object EventParser {
|
|||||||
|
|
||||||
"status.update" -> {
|
"status.update" -> {
|
||||||
val status = payload?.get("status") as? String
|
val status = payload?.get("status") as? String
|
||||||
WsEvent.StatusUpdate(status, payload)
|
WsEvent.StatusUpdate(status, payload, sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
"error" -> {
|
"error" -> {
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ sealed class WsEvent {
|
|||||||
data class StatusUpdate(
|
data class StatusUpdate(
|
||||||
val status: String?,
|
val status: String?,
|
||||||
val data: Map<String, Any?>?,
|
val data: Map<String, Any?>?,
|
||||||
|
val sessionId: String? = null,
|
||||||
) : WsEvent()
|
) : WsEvent()
|
||||||
|
|
||||||
data class SessionUpdated(
|
data class SessionUpdated(
|
||||||
|
|||||||
@@ -11,9 +11,21 @@ data class SessionCreateParams(
|
|||||||
@SerialName("hidden") val hidden: Boolean? = null,
|
@SerialName("hidden") val hidden: Boolean? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `source` this client declares for every session it mints or resumes.
|
||||||
|
*
|
||||||
|
* Mobile presents the desktop surface, so it claims `"desktop"` on BOTH `session.create` and
|
||||||
|
* `session.resume`. The gateway resolves an omitted `source` from the host environment
|
||||||
|
* (`_resolve_session_source`), which on a headless backend is `"tui"`; a resumed runtime then
|
||||||
|
* reads as a surface switch and loses the `desktop_ui` toolset (#1450). One constant so the
|
||||||
|
* create and resume call sites cannot drift apart again.
|
||||||
|
*/
|
||||||
|
const val DESKTOP_SESSION_SOURCE = "desktop"
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SessionResumeParams(
|
data class SessionResumeParams(
|
||||||
@SerialName("session_id") val sessionId: String,
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
@SerialName("source") val source: String? = null,
|
||||||
@SerialName("omit_messages") val omitMessages: Boolean? = null,
|
@SerialName("omit_messages") val omitMessages: Boolean? = null,
|
||||||
@SerialName("profile") val profile: String? = null,
|
@SerialName("profile") val profile: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import com.m57.hermescontrol.R
|
|||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.DESKTOP_SESSION_SOURCE
|
||||||
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
|
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
|
||||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||||
import com.m57.hermescontrol.data.ws.contract.SessionResumeParams
|
import com.m57.hermescontrol.data.ws.contract.SessionResumeParams
|
||||||
@@ -157,6 +158,7 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
|||||||
val params =
|
val params =
|
||||||
SessionResumeParams(
|
SessionResumeParams(
|
||||||
sessionId = storedSessionId,
|
sessionId = storedSessionId,
|
||||||
|
source = DESKTOP_SESSION_SOURCE,
|
||||||
omitMessages = true,
|
omitMessages = true,
|
||||||
profile = profile,
|
profile = profile,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import com.m57.hermescontrol.data.remote.safeApiCall
|
|||||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.data.ws.WsEvent
|
import com.m57.hermescontrol.data.ws.WsEvent
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.DESKTOP_SESSION_SOURCE
|
||||||
import com.m57.hermescontrol.data.ws.contract.ProfilesConfigureParams
|
import com.m57.hermescontrol.data.ws.contract.ProfilesConfigureParams
|
||||||
import com.m57.hermescontrol.data.ws.contract.ProfilesListParams
|
import com.m57.hermescontrol.data.ws.contract.ProfilesListParams
|
||||||
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
|
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
|
||||||
@@ -564,7 +565,7 @@ class GroupChatViewModel(
|
|||||||
method = RpcMethods.SESSION_CREATE,
|
method = RpcMethods.SESSION_CREATE,
|
||||||
params =
|
params =
|
||||||
SessionCreateParams(
|
SessionCreateParams(
|
||||||
source = "desktop",
|
source = DESKTOP_SESSION_SOURCE,
|
||||||
profile = bot.name,
|
profile = bot.name,
|
||||||
title = title,
|
title = title,
|
||||||
hidden = true,
|
hidden = true,
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
/** Captured before asynchronous clipboard access; never retarget a late image to a new chat. */
|
||||||
|
internal data class ChatAttachmentTarget(
|
||||||
|
val sessionId: String,
|
||||||
|
val generation: Long,
|
||||||
|
val baseUrl: String,
|
||||||
|
val connectionProfileId: String?,
|
||||||
|
val agentProfileId: String?,
|
||||||
|
)
|
||||||
@@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.m57.hermescontrol.ui.chat.fullbleed.isHiddenTool
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every [TOOL_CALL_DIVIDER_INTERVAL]th tool call within a single turn renders a
|
* Every [TOOL_CALL_DIVIDER_INTERVAL]th tool call within a single turn renders a
|
||||||
@@ -56,7 +57,7 @@ fun toolCallMilestones(messages: List<ChatMessage>): Map<String, Int> {
|
|||||||
toolCount = 0
|
toolCount = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
message.role == MessageRole.TOOL -> {
|
message.role == MessageRole.TOOL && !message.isHiddenTool() -> {
|
||||||
toolCount += 1
|
toolCount += 1
|
||||||
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
|
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
|
||||||
milestones[message.id] = toolCount
|
milestones[message.id] = toolCount
|
||||||
@@ -79,7 +80,7 @@ fun toolCallMilestoneIndices(messages: List<ChatMessage>): Map<Int, Int> {
|
|||||||
toolCount = 0
|
toolCount = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
message.role == MessageRole.TOOL -> {
|
message.role == MessageRole.TOOL && !message.isHiddenTool() -> {
|
||||||
toolCount += 1
|
toolCount += 1
|
||||||
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
|
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
|
||||||
milestones[index] = toolCount
|
milestones[index] = toolCount
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
private const val PASTED_IMAGE_DIRECTORY = "pasted_images"
|
||||||
|
private const val PASTED_IMAGE_MAX_AGE_MS = 7L * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startup-only sweep (#1444). Keep recent files for transcript previews/retries;
|
||||||
|
* never run during a live session where an old attachment may still be uploading.
|
||||||
|
*/
|
||||||
|
internal fun cleanStalePastedImages(
|
||||||
|
cacheDir: File,
|
||||||
|
nowMs: Long = System.currentTimeMillis(),
|
||||||
|
) {
|
||||||
|
val directory = File(cacheDir, PASTED_IMAGE_DIRECTORY)
|
||||||
|
val files = runCatching { directory.listFiles() }.getOrNull() ?: return
|
||||||
|
val cutoff = nowMs - PASTED_IMAGE_MAX_AGE_MS
|
||||||
|
for (file in files) {
|
||||||
|
runCatching {
|
||||||
|
// Do not traverse subdirectories or touch any other cache namespace.
|
||||||
|
if (file.isFile && file.lastModified() < cutoff) file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class PastedImageTooLargeException : IOException("Pasted image exceeds attachment limit")
|
||||||
|
|
||||||
|
/** A private cache file owned by the caller after [ChatPastedImageStore.importImage] returns. */
|
||||||
|
class PastedImage(
|
||||||
|
val attachment: Attachment,
|
||||||
|
val file: File,
|
||||||
|
) {
|
||||||
|
/** Call if the import cannot be queued (for example, after a session switch). */
|
||||||
|
fun discard() {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Explicit clipboard/IME image importer; it never observes the clipboard itself. */
|
||||||
|
class ChatPastedImageStore(
|
||||||
|
private val context: Context,
|
||||||
|
) {
|
||||||
|
/** Returns null for unsupported, missing, or empty input; oversized streams fail explicitly. */
|
||||||
|
suspend fun importImage(
|
||||||
|
uri: Uri,
|
||||||
|
maxBytes: Long = MAX_CHAT_ATTACHMENT_BYTES,
|
||||||
|
): PastedImage? {
|
||||||
|
var pending: PastedImage? = null
|
||||||
|
var delivered = false
|
||||||
|
try {
|
||||||
|
val result =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (uri.scheme != "content") return@withContext null
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val mimeType =
|
||||||
|
resolver
|
||||||
|
.getType(uri)
|
||||||
|
?.takeIf { it.startsWith("image/", ignoreCase = true) && it.length > "image/".length }
|
||||||
|
?: return@withContext null
|
||||||
|
val extension =
|
||||||
|
MimeTypeMap
|
||||||
|
.getSingleton()
|
||||||
|
.getExtensionFromMimeType(mimeType)
|
||||||
|
?.takeIf { it.matches(Regex("[A-Za-z0-9]{1,10}")) }
|
||||||
|
?: "img"
|
||||||
|
val input = resolver.openInputStream(uri) ?: return@withContext null
|
||||||
|
val activeContext = currentCoroutineContext()
|
||||||
|
val file =
|
||||||
|
stagePastedImage(
|
||||||
|
input = input,
|
||||||
|
directory = File(context.cacheDir, PASTED_IMAGE_DIRECTORY),
|
||||||
|
extension = extension,
|
||||||
|
maxBytes = maxBytes,
|
||||||
|
checkActive = { activeContext.ensureActive() },
|
||||||
|
) ?: return@withContext null
|
||||||
|
try {
|
||||||
|
activeContext.ensureActive()
|
||||||
|
val privateUri =
|
||||||
|
FileProvider.getUriForFile(
|
||||||
|
context,
|
||||||
|
"${context.packageName}.fileprovider",
|
||||||
|
file,
|
||||||
|
)
|
||||||
|
PastedImage(
|
||||||
|
attachment =
|
||||||
|
Attachment(
|
||||||
|
uri = privateUri.toString(),
|
||||||
|
name = file.name,
|
||||||
|
mimeType = mimeType,
|
||||||
|
size = file.length(),
|
||||||
|
),
|
||||||
|
file = file,
|
||||||
|
).also { pending = it }
|
||||||
|
} catch (failure: Throwable) {
|
||||||
|
file.delete()
|
||||||
|
throw failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delivered = true
|
||||||
|
return result
|
||||||
|
} finally {
|
||||||
|
// withContext may discard its completed IO result if the parent was cancelled
|
||||||
|
// during dispatch back; in that case ownership never reached the caller.
|
||||||
|
if (!delivered) pending?.discard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JVM-testable, exclusive-create bounded copy. Null means empty input. */
|
||||||
|
internal fun stagePastedImage(
|
||||||
|
input: InputStream,
|
||||||
|
directory: File,
|
||||||
|
extension: String,
|
||||||
|
maxBytes: Long = MAX_CHAT_ATTACHMENT_BYTES,
|
||||||
|
checkActive: () -> Unit = {},
|
||||||
|
newId: () -> String = { UUID.randomUUID().toString() },
|
||||||
|
): File? {
|
||||||
|
require(maxBytes > 0) { "maxBytes must be positive" }
|
||||||
|
val effectiveMaxBytes = minOf(maxBytes, MAX_CHAT_ATTACHMENT_BYTES)
|
||||||
|
require(extension.matches(Regex("[A-Za-z0-9]{1,10}"))) { "Invalid extension" }
|
||||||
|
var owned: File? = null
|
||||||
|
var keep = false
|
||||||
|
try {
|
||||||
|
return input
|
||||||
|
.use source@{ source ->
|
||||||
|
if (!directory.isDirectory && !directory.mkdirs() && !directory.isDirectory) {
|
||||||
|
throw IOException("Cannot create pasted image cache directory")
|
||||||
|
}
|
||||||
|
val file = File(directory, "${UUID.fromString(newId())}.$extension")
|
||||||
|
if (!file.createNewFile()) throw IOException("Pasted image cache name already exists")
|
||||||
|
owned = file
|
||||||
|
var total = 0L
|
||||||
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
|
FileOutputStream(file).use { output ->
|
||||||
|
while (true) {
|
||||||
|
checkActive()
|
||||||
|
// Probe one byte beyond the limit without trusting provider-reported size.
|
||||||
|
val remaining = effectiveMaxBytes - total
|
||||||
|
val requested = if (remaining < buffer.size) (remaining + 1).toInt() else buffer.size
|
||||||
|
val count = source.read(buffer, 0, requested)
|
||||||
|
if (count < 0) break
|
||||||
|
if (count > remaining) throw PastedImageTooLargeException()
|
||||||
|
if (count == 0) {
|
||||||
|
val byte = source.read()
|
||||||
|
if (byte < 0) break
|
||||||
|
if (remaining == 0L) throw PastedImageTooLargeException()
|
||||||
|
output.write(byte)
|
||||||
|
total++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
output.write(buffer, 0, count)
|
||||||
|
total += count
|
||||||
|
}
|
||||||
|
checkActive()
|
||||||
|
}
|
||||||
|
if (total == 0L) null else file
|
||||||
|
}.also { keep = it != null }
|
||||||
|
} finally {
|
||||||
|
if (!keep) owned?.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.input.clearText
|
||||||
|
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
@@ -70,7 +72,6 @@ import androidx.compose.ui.platform.testTag
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
@@ -118,9 +119,12 @@ import com.m57.hermescontrol.ui.chat.components.SpeechRequest
|
|||||||
import com.m57.hermescontrol.ui.chat.components.SpeechText
|
import com.m57.hermescontrol.ui.chat.components.SpeechText
|
||||||
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
|
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
|
||||||
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
|
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
|
||||||
|
import com.m57.hermescontrol.ui.chat.components.rememberChatImagePaste
|
||||||
import com.m57.hermescontrol.ui.chat.components.rememberChatMediaLaunchers
|
import com.m57.hermescontrol.ui.chat.components.rememberChatMediaLaunchers
|
||||||
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
||||||
import com.m57.hermescontrol.ui.chat.components.rememberChatSpeech
|
import com.m57.hermescontrol.ui.chat.components.rememberChatSpeech
|
||||||
|
import com.m57.hermescontrol.ui.chat.components.replaceComposerDraft
|
||||||
|
import com.m57.hermescontrol.ui.chat.components.restoreRejectedComposerDraft
|
||||||
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
|
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
|
||||||
import com.m57.hermescontrol.ui.chat.components.tailContentKey
|
import com.m57.hermescontrol.ui.chat.components.tailContentKey
|
||||||
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
|
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
|
||||||
@@ -338,20 +342,18 @@ fun ChatScreen(
|
|||||||
!timelineState.isHistorical && scrollController.showFab(displayedMessages.isNotEmpty())
|
!timelineState.isHistorical && scrollController.showFab(displayedMessages.isNotEmpty())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
val inputState = rememberTextFieldState()
|
||||||
mutableStateOf(TextFieldValue(""))
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(state.pendingPrefillText) {
|
LaunchedEffect(state.pendingPrefillText) {
|
||||||
val prefill = state.pendingPrefillText
|
val prefill = state.pendingPrefillText
|
||||||
if (prefill != null) {
|
if (prefill != null) {
|
||||||
inputFieldValue = ChatInputPolicy.commandFieldValue(prefill)
|
inputState.replaceComposerDraft(prefill)
|
||||||
viewModel.consumePendingPrefill()
|
viewModel.consumePendingPrefill()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(state.composerTextToRestore) {
|
LaunchedEffect(state.composerTextToRestore) {
|
||||||
state.composerTextToRestore?.let { text ->
|
state.composerTextToRestore?.let { text ->
|
||||||
inputFieldValue = ChatInputPolicy.restoreRejectedText(text, inputFieldValue)
|
inputState.restoreRejectedComposerDraft(text)
|
||||||
viewModel.consumeComposerTextRestore()
|
viewModel.consumeComposerTextRestore()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -364,6 +366,10 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
|
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val imagePaste =
|
||||||
|
rememberChatImagePaste(viewModel) { message ->
|
||||||
|
scrollScope.launch { snackbarHostState.showSnackbar(message) }
|
||||||
|
}
|
||||||
val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
|
val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
|
||||||
ExternalActivityLifecycleGuard.launchExternalActivity(
|
ExternalActivityLifecycleGuard.launchExternalActivity(
|
||||||
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
|
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
|
||||||
@@ -411,8 +417,7 @@ fun ChatScreen(
|
|||||||
|
|
||||||
val mediaLaunchers =
|
val mediaLaunchers =
|
||||||
rememberChatMediaLaunchers(
|
rememberChatMediaLaunchers(
|
||||||
inputFieldValue = inputFieldValue,
|
inputState = inputState,
|
||||||
onInputFieldValueChange = { inputFieldValue = it },
|
|
||||||
onAddAttachment = { uri, name, mimeType, size ->
|
onAddAttachment = { uri, name, mimeType, size ->
|
||||||
viewModel.addAttachment(uri, name, mimeType, size)
|
viewModel.addAttachment(uri, name, mimeType, size)
|
||||||
},
|
},
|
||||||
@@ -865,11 +870,12 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
ChatInputBar(
|
ChatInputBar(
|
||||||
inputFieldValue = inputFieldValue,
|
inputState = inputState,
|
||||||
onInputChange = { inputFieldValue = it },
|
receiveContentListener = imagePaste,
|
||||||
|
isReceivingContent = imagePaste.isReceiving,
|
||||||
onSend = {
|
onSend = {
|
||||||
if (viewModel.sendMessage(inputFieldValue.text)) {
|
if (!imagePaste.isReceiving && viewModel.sendMessage(inputState.text.toString())) {
|
||||||
inputFieldValue = TextFieldValue("")
|
inputState.clearText()
|
||||||
// Jump only after an accepted send. A readiness race keeps the draft intact.
|
// Jump only after an accepted send. A readiness race keeps the draft intact.
|
||||||
scrollController.jumpToBottom(animated = true)
|
scrollController.jumpToBottom(animated = true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol.ui.chat
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.ui.chat.fullbleed.isHiddenTool
|
||||||
import com.m57.hermescontrol.ui.chat.fullbleed.isSyntheticSystemRow
|
import com.m57.hermescontrol.ui.chat.fullbleed.isSyntheticSystemRow
|
||||||
import com.m57.hermescontrol.ui.chat.fullbleed.isTimelineMarker
|
import com.m57.hermescontrol.ui.chat.fullbleed.isTimelineMarker
|
||||||
import com.m57.hermescontrol.ui.chat.tool.ToolViewCache
|
import com.m57.hermescontrol.ui.chat.tool.ToolViewCache
|
||||||
@@ -100,7 +101,7 @@ class ChatSearchController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
MessageRole.TOOL -> {
|
MessageRole.TOOL -> {
|
||||||
scan(idx, searchableToolText(message), SearchTarget.TOOL)
|
if (!message.isHiddenTool()) scan(idx, searchableToolText(message), SearchTarget.TOOL)
|
||||||
}
|
}
|
||||||
|
|
||||||
MessageRole.SYSTEM -> {
|
MessageRole.SYSTEM -> {
|
||||||
|
|||||||
@@ -61,6 +61,25 @@ internal fun pendingSendIdsConfirmedByDurableAliases(
|
|||||||
.toSet()
|
.toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #1427: the `prompt.submit` `user_row_id` is the exact gateway row for that send, so a history
|
||||||
|
* page containing it proves delivery even when the merge could not alias the local bubble.
|
||||||
|
*/
|
||||||
|
internal fun pendingSendIdsConfirmedByRowIds(
|
||||||
|
pageRowIds: Set<Long>,
|
||||||
|
pending: List<PendingSend>,
|
||||||
|
): Set<String> =
|
||||||
|
pending
|
||||||
|
.asSequence()
|
||||||
|
.filter {
|
||||||
|
it.state in setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN)
|
||||||
|
}.filter { it.userRowId != null && it.userRowId in pageRowIds }
|
||||||
|
.map { it.id }
|
||||||
|
.toSet()
|
||||||
|
|
||||||
|
/** #1427: a receipt holding a gateway `user_row_id` is stored server-side and must never become UNKNOWN. */
|
||||||
|
internal fun canDemoteAcceptedReceipt(receipt: PendingSend): Boolean = receipt.userRowId == null
|
||||||
|
|
||||||
/** Synchronous writes keep the queue recoverable when Android kills the process just after a tap. */
|
/** Synchronous writes keep the queue recoverable when Android kills the process just after a tap. */
|
||||||
class ChatSendStore(
|
class ChatSendStore(
|
||||||
private val prefs: SharedPreferences? = null,
|
private val prefs: SharedPreferences? = null,
|
||||||
|
|||||||
@@ -232,7 +232,9 @@ internal fun mapServerMessages(
|
|||||||
rawContent
|
rawContent
|
||||||
}
|
}
|
||||||
var attachments: List<Attachment>? = null
|
var attachments: List<Attachment>? = null
|
||||||
if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) {
|
if (role == MessageRole.USER) {
|
||||||
|
attachments = userImageAttachments(finalContent, mediaUrl).takeIf { it.isNotEmpty() }
|
||||||
|
} else if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) {
|
||||||
val items = HostMediaExtractor.extract(rawContent)
|
val items = HostMediaExtractor.extract(rawContent)
|
||||||
if (items.isNotEmpty()) {
|
if (items.isNotEmpty()) {
|
||||||
finalContent = HostMediaExtractor.strip(rawContent)
|
finalContent = HostMediaExtractor.strip(rawContent)
|
||||||
@@ -275,7 +277,7 @@ internal fun mapServerMessages(
|
|||||||
completionId = completionId,
|
completionId = completionId,
|
||||||
serverRowId = msg.id?.toLong()?.takeIf { it > 0L },
|
serverRowId = msg.id?.toLong()?.takeIf { it > 0L },
|
||||||
reactions = msg.reactions,
|
reactions = msg.reactions,
|
||||||
),
|
).normalizedGatewayNotice(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -417,6 +417,12 @@ internal fun mergeCachedTranscriptPage(
|
|||||||
match.id to
|
match.id to
|
||||||
rich.copy(
|
rich.copy(
|
||||||
id = match.id,
|
id = match.id,
|
||||||
|
attachments =
|
||||||
|
if (rich.role == MessageRole.USER) {
|
||||||
|
rich.attachments?.takeIf { it.isNotEmpty() } ?: message.attachments
|
||||||
|
} else {
|
||||||
|
rich.attachments
|
||||||
|
},
|
||||||
content = preservedContent ?: rich.content,
|
content = preservedContent ?: rich.content,
|
||||||
restId = match.canonicalRestId ?: message.canonicalRestId,
|
restId = match.canonicalRestId ?: message.canonicalRestId,
|
||||||
serverRowId = match.serverRowId ?: message.serverRowId,
|
serverRowId = match.serverRowId ?: message.serverRowId,
|
||||||
@@ -457,6 +463,15 @@ internal fun mergeTranscriptWithLive(
|
|||||||
when {
|
when {
|
||||||
match?.role == MessageRole.USER -> {
|
match?.role == MessageRole.USER -> {
|
||||||
match.copy(
|
match.copy(
|
||||||
|
// #1432: cached user rows lack attachment metadata; hydrate from REST,
|
||||||
|
// while preserving richer optimistic/local attachments when present.
|
||||||
|
attachments = match.attachments?.takeIf { it.isNotEmpty() } ?: message.attachments,
|
||||||
|
content =
|
||||||
|
if (match.isHistoricalCache && !message.attachments.isNullOrEmpty()) {
|
||||||
|
message.content
|
||||||
|
} else {
|
||||||
|
match.content
|
||||||
|
},
|
||||||
restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id },
|
restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id },
|
||||||
serverRowId = message.serverRowId ?: match.serverRowId,
|
serverRowId = message.serverRowId ?: match.serverRowId,
|
||||||
reactions = message.reactions.ifEmpty { match.reactions },
|
reactions = message.reactions.ifEmpty { match.reactions },
|
||||||
@@ -531,14 +546,21 @@ internal fun mergeTranscriptWithLive(
|
|||||||
.mapNotNull { index ->
|
.mapNotNull { index ->
|
||||||
matches[index]?.id?.let { id -> incoming[index].canonicalOrder?.let { id to it } }
|
matches[index]?.id?.let { id -> incoming[index].canonicalOrder?.let { id to it } }
|
||||||
}.toMap()
|
}.toMap()
|
||||||
return dedupeCachedMessages(transcript.inTranscriptOrder(current, resolvedOrders), confirmedOnly = true)
|
return dedupeCachedMessages(
|
||||||
.reconcileReasoningRows()
|
transcript.inTranscriptOrder(
|
||||||
|
previous = current,
|
||||||
|
resolvedOrders = resolvedOrders,
|
||||||
|
observedSuccessorAnchors = current,
|
||||||
|
),
|
||||||
|
confirmedOnly = true,
|
||||||
|
).reconcileReasoningRows()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keep server order; place local notices and commands after their last preceding confirmed message, not at the transcript tail. */
|
/** Keep server order; place local notices and commands after their last preceding confirmed message, not at the transcript tail. */
|
||||||
private fun List<ChatMessage>.inTranscriptOrder(
|
private fun List<ChatMessage>.inTranscriptOrder(
|
||||||
previous: List<ChatMessage>,
|
previous: List<ChatMessage>,
|
||||||
resolvedOrders: Map<String, Long>,
|
resolvedOrders: Map<String, Long>,
|
||||||
|
observedSuccessorAnchors: List<ChatMessage>? = null,
|
||||||
): List<ChatMessage> {
|
): List<ChatMessage> {
|
||||||
val latestCanonical = mapNotNull { it.canonicalOrder }.maxOrNull() ?: -1L
|
val latestCanonical = mapNotNull { it.canonicalOrder }.maxOrNull() ?: -1L
|
||||||
// Session-start markers use -1 and must remain before unresolved cached history.
|
// Session-start markers use -1 and must remain before unresolved cached history.
|
||||||
@@ -549,6 +571,21 @@ private fun List<ChatMessage>.inTranscriptOrder(
|
|||||||
var pendingLocalOrder: Long? = null
|
var pendingLocalOrder: Long? = null
|
||||||
val localAnchors = mutableMapOf<String, Long>()
|
val localAnchors = mutableMapOf<String, Long>()
|
||||||
val pendingOrderByLocal = mutableMapOf<String, Long>()
|
val pendingOrderByLocal = mutableMapOf<String, Long>()
|
||||||
|
// #1451: an observed live block must stay before its later confirmed USER successor,
|
||||||
|
// even when its own REST echoes are absent. Successor anchors derive only from genuinely
|
||||||
|
// observed current lists (never cache concatenations) and only from later USER prompts.
|
||||||
|
val nextCanonicalOrder = mutableMapOf<String, Long>()
|
||||||
|
val anchorSource = observedSuccessorAnchors ?: emptyList()
|
||||||
|
var followingUserCanonical: Long? = null
|
||||||
|
for (i in anchorSource.indices.reversed()) {
|
||||||
|
val message = anchorSource[i]
|
||||||
|
val order = resolvedOrders[message.id] ?: message.canonicalOrder
|
||||||
|
if (order != null && message.role == MessageRole.USER) {
|
||||||
|
followingUserCanonical = order
|
||||||
|
} else if (followingUserCanonical != null) {
|
||||||
|
nextCanonicalOrder[message.id] = followingUserCanonical
|
||||||
|
}
|
||||||
|
}
|
||||||
previous.forEach { message ->
|
previous.forEach { message ->
|
||||||
val order = resolvedOrders[message.id] ?: message.canonicalOrder
|
val order = resolvedOrders[message.id] ?: message.canonicalOrder
|
||||||
if (order != null) {
|
if (order != null) {
|
||||||
@@ -563,7 +600,7 @@ private fun List<ChatMessage>.inTranscriptOrder(
|
|||||||
} else if (message.isPermanentlyLocal()) {
|
} else if (message.isPermanentlyLocal()) {
|
||||||
localAnchors[message.id] =
|
localAnchors[message.id] =
|
||||||
if (hasPendingPredecessor) {
|
if (hasPendingPredecessor) {
|
||||||
Long.MAX_VALUE
|
nextCanonicalOrder[message.id]?.let { it - 1L } ?: Long.MAX_VALUE
|
||||||
} else {
|
} else {
|
||||||
precedingCanonical?.takeIf { it >= 0L }
|
precedingCanonical?.takeIf { it >= 0L }
|
||||||
?: if (message.role == MessageRole.USER) {
|
?: if (message.role == MessageRole.USER) {
|
||||||
@@ -581,6 +618,9 @@ private fun List<ChatMessage>.inTranscriptOrder(
|
|||||||
} else {
|
} else {
|
||||||
hasPendingPredecessor = true
|
hasPendingPredecessor = true
|
||||||
pendingLocalOrder = message.localOrder
|
pendingLocalOrder = message.localOrder
|
||||||
|
nextCanonicalOrder[message.id]?.let { successor ->
|
||||||
|
localAnchors[message.id] = successor - 1L
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val previousIndices = previous.withIndex().associate { it.value.id to it.index }
|
val previousIndices = previous.withIndex().associate { it.value.id to it.index }
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import com.m57.hermescontrol.data.ws.contract.CommandDispatchParams
|
|||||||
import com.m57.hermescontrol.data.ws.contract.CommandsCatalogParams
|
import com.m57.hermescontrol.data.ws.contract.CommandsCatalogParams
|
||||||
import com.m57.hermescontrol.data.ws.contract.ConfigGetParams
|
import com.m57.hermescontrol.data.ws.contract.ConfigGetParams
|
||||||
import com.m57.hermescontrol.data.ws.contract.ConfigSetParams
|
import com.m57.hermescontrol.data.ws.contract.ConfigSetParams
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.DESKTOP_SESSION_SOURCE
|
||||||
import com.m57.hermescontrol.data.ws.contract.FileAttachParams
|
import com.m57.hermescontrol.data.ws.contract.FileAttachParams
|
||||||
import com.m57.hermescontrol.data.ws.contract.ImageAttachBytesParams
|
import com.m57.hermescontrol.data.ws.contract.ImageAttachBytesParams
|
||||||
import com.m57.hermescontrol.data.ws.contract.ProcessStopParams
|
import com.m57.hermescontrol.data.ws.contract.ProcessStopParams
|
||||||
@@ -1511,6 +1512,10 @@ class ChatViewModel(
|
|||||||
viewModelScope.launch { fetchContextUsage() }
|
viewModelScope.launch { fetchContextUsage() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is ReducerEffect.DeleteLocalMessage -> {
|
||||||
|
viewModelScope.launch(ioDispatcher) { repo.deleteMessage(effect.messageId) }
|
||||||
|
}
|
||||||
|
|
||||||
is ReducerEffect.AttachHostMedia -> {
|
is ReducerEffect.AttachHostMedia -> {
|
||||||
// Issue #724: turn host-path MEDIA: directives into real
|
// Issue #724: turn host-path MEDIA: directives into real
|
||||||
// attachments (images inline, every other file tappable)
|
// attachments (images inline, every other file tappable)
|
||||||
@@ -1804,6 +1809,8 @@ class ChatViewModel(
|
|||||||
contextBreakdown = null,
|
contextBreakdown = null,
|
||||||
compressionCount = null,
|
compressionCount = null,
|
||||||
sessionUsage = null,
|
sessionUsage = null,
|
||||||
|
isCompressing = false,
|
||||||
|
compressionStatus = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
publishPendingSends()
|
publishPendingSends()
|
||||||
@@ -3676,6 +3683,34 @@ class ChatViewModel(
|
|||||||
|
|
||||||
fun addAttachments(attachments: List<Attachment>) = attachmentsDelegate.addAttachments(attachments)
|
fun addAttachments(attachments: List<Attachment>) = attachmentsDelegate.addAttachments(attachments)
|
||||||
|
|
||||||
|
/** Clipboard reads are asynchronous: bind them to this exact session generation and server. */
|
||||||
|
internal fun captureAttachmentTarget(): ChatAttachmentTarget? {
|
||||||
|
val state = _uiState.value
|
||||||
|
val sessionId = state.currentSessionId ?: return null
|
||||||
|
// Connection status is combined into public uiState, not written to _uiState.
|
||||||
|
if (wsClient.connectionStatus.value != ConnectionStatus.CONNECTED ||
|
||||||
|
!state.isSessionReady || _timelineState.value.isHistorical
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return ChatAttachmentTarget(
|
||||||
|
sessionId = sessionId,
|
||||||
|
generation = sessionGeneration,
|
||||||
|
baseUrl = AuthManager.getBaseUrl(),
|
||||||
|
connectionProfileId = AuthManager.getSelectedProfileId(),
|
||||||
|
agentProfileId = AuthManager.activeProfileId.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun addPastedAttachments(
|
||||||
|
target: ChatAttachmentTarget,
|
||||||
|
attachments: List<Attachment>,
|
||||||
|
): Boolean {
|
||||||
|
if (captureAttachmentTarget() != target) return false
|
||||||
|
attachmentsDelegate.addAttachments(attachments)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
fun removeAttachment(index: Int) = attachmentsDelegate.removeAttachment(index)
|
fun removeAttachment(index: Int) = attachmentsDelegate.removeAttachment(index)
|
||||||
|
|
||||||
fun openAttachment(attachment: Attachment) = mediaDelegate.openAttachment(attachment)
|
fun openAttachment(attachment: Attachment) = mediaDelegate.openAttachment(attachment)
|
||||||
@@ -4127,44 +4162,46 @@ class ChatViewModel(
|
|||||||
val arg = parts.getOrElse(1) { "" }
|
val arg = parts.getOrElse(1) { "" }
|
||||||
viewModelScope.launch(ioDispatcher) {
|
viewModelScope.launch(ioDispatcher) {
|
||||||
try {
|
try {
|
||||||
// Primary path: command.dispatch handles quick/plugin/bundle/
|
// Desktop parity: slash.exec owns generic commands. Handle errors here,
|
||||||
// skill commands + a few hardcoded ones. It returns a hard 4018
|
// not again through the shared RpcError banner (/usage regression).
|
||||||
// "not a ... command" for everything that lives only in the TUI
|
val result =
|
||||||
// slash worker (the 29 commands that 4018'd on mobile — issue
|
wsClient.call(
|
||||||
// #576). For those we fall back to slash.exec, which runs the
|
RpcMethods.SLASH_EXEC,
|
||||||
// full COMMAND_REGISTRY through the worker.
|
SlashExecParams(sessionId = sessionId, command = command.removePrefix("/")),
|
||||||
|
suppressErrorEvent = true,
|
||||||
|
)
|
||||||
|
val map = result.toAny() as? Map<*, *>
|
||||||
|
if (map?.get("type") is String) {
|
||||||
|
handleDispatchResult(map)
|
||||||
|
} else {
|
||||||
|
val output = (map?.get("output") as? String).orEmpty().ifBlank { "/$name: no output" }
|
||||||
|
val warning = (map?.get("warning") as? String).orEmpty()
|
||||||
|
addAssistantMessage(if (warning.isBlank()) output else "warning: $warning\n$output")
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
try {
|
||||||
val result =
|
val result =
|
||||||
wsClient.call(
|
wsClient.call(
|
||||||
RpcMethods.COMMAND_DISPATCH,
|
RpcMethods.COMMAND_DISPATCH,
|
||||||
CommandDispatchParams(name = name, arg = arg, sessionId = sessionId),
|
CommandDispatchParams(name = name, arg = arg, sessionId = sessionId),
|
||||||
|
suppressErrorEvent = true,
|
||||||
)
|
)
|
||||||
handleDispatchResult(result.toAny())
|
val map = result.toAny() as? Map<*, *>
|
||||||
} catch (e: HermesWsClient.HermesRpcException) {
|
if (map?.get("type") is String) {
|
||||||
val msg = e.message.orEmpty()
|
handleDispatchResult(map)
|
||||||
// Registry miss on command.dispatch: the backend emits exactly
|
|
||||||
// "not a quick/plugin/bundle/skill command: <name>" (tui_gateway
|
|
||||||
// server.py L12408). Match that precise phrase so unrelated
|
|
||||||
// errors can't accidentally trigger the slash.exec fallback.
|
|
||||||
if (msg.contains("not a quick/plugin/bundle/skill command")) {
|
|
||||||
// Registry miss on command.dispatch -> retry via slash.exec,
|
|
||||||
// which routes the full CLI command set through the worker.
|
|
||||||
try {
|
|
||||||
val result =
|
|
||||||
wsClient.call(
|
|
||||||
RpcMethods.SLASH_EXEC,
|
|
||||||
SlashExecParams(
|
|
||||||
sessionId = sessionId,
|
|
||||||
command = "/$name${if (arg.isNotEmpty()) " $arg" else ""}",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val output = (result.toAny() as? Map<*, *>)?.get("output") as? String
|
|
||||||
if (!output.isNullOrBlank()) addAssistantMessage(output)
|
|
||||||
} catch (e2: HermesWsClient.HermesRpcException) {
|
|
||||||
addAssistantMessage("/$name: ${e2.message}")
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Legit error from command.dispatch (busy, no history, etc.)
|
addAssistantMessage("/$name: invalid response: command.dispatch")
|
||||||
addAssistantMessage("/$name: ${e.message}")
|
}
|
||||||
|
} catch (fallback: CancellationException) {
|
||||||
|
throw fallback
|
||||||
|
} catch (fallback: Exception) {
|
||||||
|
val registryMiss =
|
||||||
|
Regex("not a quick/plugin/(?:bundle/)?skill command", RegexOption.IGNORE_CASE)
|
||||||
|
.containsMatchIn(fallback.message.orEmpty())
|
||||||
|
val failure = if (registryMiss) e else fallback
|
||||||
|
addAssistantMessage("/$name: ${failure.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4534,7 +4571,7 @@ class ChatViewModel(
|
|||||||
viewModelScope.launch(ioDispatcher) {
|
viewModelScope.launch(ioDispatcher) {
|
||||||
wsClient.send(
|
wsClient.send(
|
||||||
RpcMethods.SESSION_CREATE,
|
RpcMethods.SESSION_CREATE,
|
||||||
SessionCreateParams(source = "desktop"),
|
SessionCreateParams(source = DESKTOP_SESSION_SOURCE),
|
||||||
onSent = { id -> trackSessionRequest(id, WsMethods.SESSION_CREATE, generation) },
|
onSent = { id -> trackSessionRequest(id, WsMethods.SESSION_CREATE, generation) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -5070,7 +5107,22 @@ class ChatViewModel(
|
|||||||
val page = withContext(historyDispatcher) { repo.loadPage(sessionId, before, MESSAGE_PAGE_SIZE) }
|
val page = withContext(historyDispatcher) { repo.loadPage(sessionId, before, MESSAGE_PAGE_SIZE) }
|
||||||
val valid = { isCurrent() && cacheCursor == before }
|
val valid = { isCurrent() && cacheCursor == before }
|
||||||
if (!valid()) return false
|
if (!valid()) return false
|
||||||
mergeHistoryPage(valid, cached = true) { page.messages } ?: return false
|
mergeHistoryPage(valid, cached = true) {
|
||||||
|
// #1432: Room retains host references, not connection-bound download URLs.
|
||||||
|
page.messages.map { message ->
|
||||||
|
if (message.role == MessageRole.USER && message.attachments.isNullOrEmpty()) {
|
||||||
|
message.copy(
|
||||||
|
attachments =
|
||||||
|
userImageAttachments(
|
||||||
|
message.content,
|
||||||
|
::gatewayMediaUrl,
|
||||||
|
).takeIf { it.isNotEmpty() },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: return false
|
||||||
if (!valid()) return false
|
if (!valid()) return false
|
||||||
cacheCursor = page.cursor
|
cacheCursor = page.cursor
|
||||||
cacheHasOlder = page.hasOlder
|
cacheHasOlder = page.hasOlder
|
||||||
@@ -5216,7 +5268,11 @@ class ChatViewModel(
|
|||||||
val scope = sendScope()
|
val scope = sendScope()
|
||||||
val scopedPending =
|
val scopedPending =
|
||||||
sendStore.all().filter { it.scope == scope && it.sessionId == sessionId }
|
sendStore.all().filter { it.scope == scope && it.sessionId == sessionId }
|
||||||
pendingSendIdsConfirmedByDurableAliases(aliases, scopedPending).forEach(::removePendingSend)
|
val pageRowIds = page.mapNotNull { it.serverRowId }.toSet()
|
||||||
|
(
|
||||||
|
pendingSendIdsConfirmedByDurableAliases(aliases, scopedPending) +
|
||||||
|
pendingSendIdsConfirmedByRowIds(pageRowIds, scopedPending)
|
||||||
|
).forEach(::removePendingSend)
|
||||||
publishPendingSends()
|
publishPendingSends()
|
||||||
drainPendingQueue()
|
drainPendingQueue()
|
||||||
}
|
}
|
||||||
@@ -5324,6 +5380,9 @@ class ChatViewModel(
|
|||||||
contextBreakdown = null,
|
contextBreakdown = null,
|
||||||
compressionCount = null,
|
compressionCount = null,
|
||||||
sessionUsage = null,
|
sessionUsage = null,
|
||||||
|
// #1433: compaction state belongs to the session being left.
|
||||||
|
isCompressing = false,
|
||||||
|
compressionStatus = null,
|
||||||
pendingAttachments = emptyList(),
|
pendingAttachments = emptyList(),
|
||||||
composerTextToRestore = null,
|
composerTextToRestore = null,
|
||||||
reactionKind = null,
|
reactionKind = null,
|
||||||
@@ -5352,6 +5411,7 @@ class ChatViewModel(
|
|||||||
val params =
|
val params =
|
||||||
SessionResumeParams(
|
SessionResumeParams(
|
||||||
sessionId = sessionId,
|
sessionId = sessionId,
|
||||||
|
source = DESKTOP_SESSION_SOURCE,
|
||||||
omitMessages = true,
|
omitMessages = true,
|
||||||
profile = profile?.takeIf { it.isNotBlank() },
|
profile = profile?.takeIf { it.isNotBlank() },
|
||||||
)
|
)
|
||||||
@@ -5709,7 +5769,8 @@ class ChatViewModel(
|
|||||||
persistHistoryPage(page, sessionId)
|
persistHistoryPage(page, sessionId)
|
||||||
if (valid() && !mainTurnBusy && mainTurnEpoch == completedTurnEpoch) {
|
if (valid() && !mainTurnBusy && mainTurnEpoch == completedTurnEpoch) {
|
||||||
unverifiedAccepted.forEach { receipt ->
|
unverifiedAccepted.forEach { receipt ->
|
||||||
if (sendStore.all().any { it == receipt }) {
|
// A gateway-issued user_row_id means the row is stored: never walk back to UNKNOWN.
|
||||||
|
if (canDemoteAcceptedReceipt(receipt) && sendStore.all().any { it == receipt }) {
|
||||||
markPendingSend(receipt.id, PendingSendState.UNKNOWN)
|
markPendingSend(receipt.id, PendingSendState.UNKNOWN)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ object ChatWsEventReducer {
|
|||||||
is WsEvent.VaultSaveLoginExpire -> event.sessionId
|
is WsEvent.VaultSaveLoginExpire -> event.sessionId
|
||||||
is WsEvent.VaultCodeRequest -> event.sessionId
|
is WsEvent.VaultCodeRequest -> event.sessionId
|
||||||
is WsEvent.VaultCodeExpire -> event.sessionId
|
is WsEvent.VaultCodeExpire -> event.sessionId
|
||||||
|
is WsEvent.StatusUpdate -> event.sessionId
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
if (eventSessionId != null && (currentSessionId == null || eventSessionId != currentSessionId)) {
|
if (eventSessionId != null && (currentSessionId == null || eventSessionId != currentSessionId)) {
|
||||||
@@ -457,6 +458,7 @@ object ChatWsEventReducer {
|
|||||||
): ReducerResult {
|
): ReducerResult {
|
||||||
val failure = replyFailureFromPayload(event.rawPayload, event.text)
|
val failure = replyFailureFromPayload(event.rawPayload, event.text)
|
||||||
if (failure != null) return onReplyFailure(state, streamingState, event, failure)
|
if (failure != null) return onReplyFailure(state, streamingState, event, failure)
|
||||||
|
if (GatewayNotice.isInterruptSentinel(event.text)) return onInterruptNotice(state, streamingState, event)
|
||||||
val finalSnapshot = event.rawPayload?.let(::parseUsageSnapshot)
|
val finalSnapshot = event.rawPayload?.let(::parseUsageSnapshot)
|
||||||
val usageState = finalSnapshot?.let { applyUsageSnapshot(state, it) } ?: state
|
val usageState = finalSnapshot?.let { applyUsageSnapshot(state, it) } ?: state
|
||||||
val turnUsage = finalSnapshot?.deltaFrom(streamingState.turnUsageBaseline)
|
val turnUsage = finalSnapshot?.deltaFrom(streamingState.turnUsageBaseline)
|
||||||
@@ -1052,36 +1054,107 @@ object ChatWsEventReducer {
|
|||||||
event: WsEvent.StatusUpdate,
|
event: WsEvent.StatusUpdate,
|
||||||
): ReducerResult {
|
): ReducerResult {
|
||||||
val kind = event.data?.get("kind") as? String
|
val kind = event.data?.get("kind") as? String
|
||||||
val text = event.data?.get("text") as? String
|
val text = (event.data?.get("text") as? String)?.trim()
|
||||||
return when (kind) {
|
return when (kind) {
|
||||||
|
// In-progress compaction is live state: the tail indicator shows only while it runs.
|
||||||
"compressing", "compacting" -> {
|
"compressing", "compacting" -> {
|
||||||
ReducerResult(
|
ReducerResult(
|
||||||
state =
|
state =
|
||||||
state.copy(
|
state.copy(
|
||||||
isCompressing = true,
|
isCompressing = true,
|
||||||
compressionStatus = text ?: "⏳ Compressing context...",
|
compressionStatus = text?.ifBlank { null } ?: "⏳ Compressing context...",
|
||||||
),
|
),
|
||||||
streamingState = streamingState,
|
streamingState = streamingState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #1433: the done edge is a transcript event, seated where it happened, never a floating tail row.
|
||||||
"compacted" -> {
|
"compacted" -> {
|
||||||
|
appendGatewayNotice(state.copy(isCompressing = false, compressionStatus = null), streamingState, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
"ready" -> {
|
||||||
ReducerResult(
|
ReducerResult(
|
||||||
state =
|
state = state.copy(isCompressing = false, compressionStatus = null),
|
||||||
state.copy(
|
|
||||||
isCompressing = false,
|
|
||||||
compressionStatus = text,
|
|
||||||
),
|
|
||||||
streamingState = streamingState,
|
streamingState = streamingState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Durable notices the desktop/TUI also paint into the transcript.
|
||||||
|
"goal", "loop", "warn", "fallback" -> {
|
||||||
|
appendGatewayNotice(state, streamingState, text)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
ReducerResult(state = state, streamingState = streamingState)
|
ReducerResult(state = state, streamingState = streamingState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends a gateway notice as a persisted SYSTEM row (#1433). SYSTEM rows are permanently local, so
|
||||||
|
* `inTranscriptOrder` anchors them after their preceding confirmed row instead of the transcript tail.
|
||||||
|
* A replayed event (reconnect `session.events.since`) re-sends the same text within the current turn;
|
||||||
|
* skip it rather than stacking a duplicate.
|
||||||
|
*/
|
||||||
|
private fun appendGatewayNotice(
|
||||||
|
state: ChatUiState,
|
||||||
|
streamingState: StreamingState,
|
||||||
|
text: String?,
|
||||||
|
extraEffects: List<ReducerEffect> = emptyList(),
|
||||||
|
): ReducerResult {
|
||||||
|
if (text.isNullOrBlank()) {
|
||||||
|
return ReducerResult(state = state, streamingState = streamingState, effects = extraEffects)
|
||||||
|
}
|
||||||
|
val turnStart = state.messages.indexOfLast { it.isUserTurnBoundary() } + 1
|
||||||
|
val duplicate =
|
||||||
|
state.messages.drop(turnStart).any { it.role == MessageRole.SYSTEM && it.content == text }
|
||||||
|
if (duplicate) return ReducerResult(state = state, streamingState = streamingState, effects = extraEffects)
|
||||||
|
val notice = ChatMessage(role = MessageRole.SYSTEM, content = text)
|
||||||
|
val sid = state.currentSessionId
|
||||||
|
return ReducerResult(
|
||||||
|
state = state.copy(messages = state.messages + notice),
|
||||||
|
streamingState = streamingState,
|
||||||
|
effects = extraEffects + listOfNotNull(sid?.let { ReducerEffect.PersistMessage(notice, it) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A turn whose final text is a gateway interrupt sentinel ends with a notice, not assistant prose (#1433).
|
||||||
|
* For the repetition-loop sentinel the gateway hides the looped partial; drop it here too, including a
|
||||||
|
* copy already sealed by Stop, or it lingers as a live-only row that jumps to the tail on every merge.
|
||||||
|
*/
|
||||||
|
private fun onInterruptNotice(
|
||||||
|
state: ChatUiState,
|
||||||
|
streamingState: StreamingState,
|
||||||
|
event: WsEvent.MessageComplete,
|
||||||
|
): ReducerResult {
|
||||||
|
val usageState = event.rawPayload?.let(::parseUsageSnapshot)?.let { applyUsageSnapshot(state, it) } ?: state
|
||||||
|
val text = event.text.trim()
|
||||||
|
val effects = mutableListOf<ReducerEffect>()
|
||||||
|
var messages = usageState.messages
|
||||||
|
if (text == GatewayNotice.REPETITION_LOOP_INTERRUPTED) {
|
||||||
|
val looped = setOfNotNull(streamingState.interruptedMessage?.id, streamingState.streamingMessage?.id)
|
||||||
|
if (looped.isNotEmpty()) {
|
||||||
|
messages = messages.filterNot { it.id in looped }
|
||||||
|
looped.forEach { effects += ReducerEffect.DeleteLocalMessage(it) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Other exits keep any real partial the user already saw.
|
||||||
|
streamingState.streamingMessage
|
||||||
|
?.takeIf { it.content.isNotBlank() || it.reasoningText.isNotBlank() }
|
||||||
|
?.copy(isStreaming = false, finishTimestamp = System.currentTimeMillis())
|
||||||
|
?.let { partial ->
|
||||||
|
messages = messages.upsertById(partial)
|
||||||
|
usageState.currentSessionId?.let { effects += ReducerEffect.PersistMessage(partial, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
effects += ReducerEffect.RefreshSessions
|
||||||
|
effects += ReducerEffect.RefreshContextUsage
|
||||||
|
val settled = usageState.copy(messages = messages, isAgentTyping = false, clarifyRequest = null)
|
||||||
|
return appendGatewayNotice(settled, StreamingState(), text, effects)
|
||||||
|
}
|
||||||
|
|
||||||
/** Paints a live tapback on the row whose gateway id matches; unmatched rows arrive with the next REST load. */
|
/** Paints a live tapback on the row whose gateway id matches; unmatched rows arrive with the next REST load. */
|
||||||
private fun onMessageReaction(
|
private fun onMessageReaction(
|
||||||
state: ChatUiState,
|
state: ChatUiState,
|
||||||
@@ -1293,6 +1366,11 @@ sealed class ReducerEffect {
|
|||||||
|
|
||||||
data object RefreshContextUsage : ReducerEffect()
|
data object RefreshContextUsage : ReducerEffect()
|
||||||
|
|
||||||
|
/** Drop a never-confirmed local row from Room (the DAO refuses rows that carry a REST identity). */
|
||||||
|
data class DeleteLocalMessage(
|
||||||
|
val messageId: String,
|
||||||
|
) : ReducerEffect()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ask the ViewModel to turn any `MEDIA:<host-path>` directives in the
|
* Ask the ViewModel to turn any `MEDIA:<host-path>` directives in the
|
||||||
* just-finalized assistant message into real [Attachment]s (images inline,
|
* just-finalized assistant message into real [Attachment]s (images inline,
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateway turn-outcome sentinels that arrive as assistant text but are notices, not prose (#1433).
|
||||||
|
*
|
||||||
|
* Sources in hermes-agent: `agent/repetition_guard.py` (`REPETITION_LOOP_INTERRUPTED`) and the
|
||||||
|
* `interrupt_text` / `close_interrupted_tool_sequence` exits ("Operation interrupted…"). They have no
|
||||||
|
* matching visible REST row (or a model-facing closing row), so as assistant prose they never get a
|
||||||
|
* canonical position and jump to the transcript tail on every merge. They render as system rows instead.
|
||||||
|
*/
|
||||||
|
internal object GatewayNotice {
|
||||||
|
const val REPETITION_LOOP_INTERRUPTED = "[the reply degenerated into a repetition loop and was interrupted]"
|
||||||
|
|
||||||
|
// "Operation interrupted." / "Operation interrupted: <reason>." / "Operation interrupted during retry (…)."
|
||||||
|
private val OPERATION_INTERRUPTED = Regex("""^Operation interrupted(?:\.|(?::| during) [^\n]*\.)$""")
|
||||||
|
|
||||||
|
fun isInterruptSentinel(text: String): Boolean {
|
||||||
|
val trimmed = text.trim()
|
||||||
|
return trimmed == REPETITION_LOOP_INTERRUPTED || OPERATION_INTERRUPTED.matches(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The gateway persists an interrupted turn's closing row as `role=assistant` with the sentinel text. Map it
|
||||||
|
* (REST and Room alike) to the same SYSTEM notice the live path appends, so every copy matches one row.
|
||||||
|
*/
|
||||||
|
internal fun ChatMessage.normalizedGatewayNotice(): ChatMessage =
|
||||||
|
if (role == MessageRole.ASSISTANT && attachments.isNullOrEmpty() && GatewayNotice.isInterruptSentinel(content)) {
|
||||||
|
copy(role = MessageRole.SYSTEM, content = content.trim(), reasoningText = "", completionId = null)
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
@@ -106,7 +106,7 @@ sealed class SlashResult {
|
|||||||
/** Create a new session (client-side immediate). */
|
/** Create a new session (client-side immediate). */
|
||||||
data object NewSession : SlashResult()
|
data object NewSession : SlashResult()
|
||||||
|
|
||||||
/** Forward to command.dispatch via WebSocket. */
|
/** Execute through slash.exec, with command.dispatch as the desktop-compatible fallback. */
|
||||||
data object RpcDispatch : SlashResult()
|
data object RpcDispatch : SlashResult()
|
||||||
|
|
||||||
/** Fork the active conversation via the session.branch WebSocket RPC. */
|
/** Fork the active conversation via the session.branch WebSocket RPC. */
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import androidx.compose.foundation.text.selection.SelectionContainer
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.Done
|
||||||
|
import androidx.compose.material.icons.filled.DoneAll
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -58,6 +60,7 @@ import com.m57.hermescontrol.theme.DarkOnSurface
|
|||||||
import com.m57.hermescontrol.theme.LightOnSurface
|
import com.m57.hermescontrol.theme.LightOnSurface
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
import com.m57.hermescontrol.ui.chat.components.rememberCopyFeedback
|
import com.m57.hermescontrol.ui.chat.components.rememberCopyFeedback
|
||||||
|
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||||
import com.m57.hermescontrol.util.BidiUtils
|
import com.m57.hermescontrol.util.BidiUtils
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -100,18 +103,36 @@ fun UserBubble(
|
|||||||
var copied by rememberCopyFeedback()
|
var copied by rememberCopyFeedback()
|
||||||
|
|
||||||
val statusColors = LocalHermesStatusColors.current
|
val statusColors = LocalHermesStatusColors.current
|
||||||
|
val deliveryState =
|
||||||
|
when {
|
||||||
|
pendingSendState == PendingSendState.SENDING -> DeliveryState.SENT
|
||||||
|
|
||||||
|
pendingSendState == PendingSendState.ACCEPTED ||
|
||||||
|
message.canonicalRestId != null || message.serverRowId != null -> DeliveryState.DELIVERED
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
// #1432: also protect cached/legacy rows and unexpectedly large plain-string payloads.
|
||||||
|
// Keep the original content for Copy; only the layout input is bounded.
|
||||||
|
val displayContent =
|
||||||
|
remember(message.content, message.attachments) {
|
||||||
|
// #1432: the path is only hidden when its image renders as an attachment below.
|
||||||
|
val visible =
|
||||||
|
if (message.attachments.isNullOrEmpty()) message.content else hideImageRefLines(message.content)
|
||||||
|
ToolJson.clampForDisplay(visible)
|
||||||
|
}
|
||||||
val highlightedText =
|
val highlightedText =
|
||||||
remember(message.content, searchQuery, isCurrentMatch, statusColors) {
|
remember(displayContent, searchQuery, isCurrentMatch, statusColors) {
|
||||||
if (searchQuery.isNotBlank()) {
|
if (searchQuery.isNotBlank()) {
|
||||||
buildHighlightedString(
|
buildHighlightedString(
|
||||||
message.content,
|
displayContent,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
isCurrentMatch,
|
isCurrentMatch,
|
||||||
statusColors,
|
statusColors,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
AnnotatedString(message.content)
|
AnnotatedString(displayContent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
@@ -153,7 +174,7 @@ fun UserBubble(
|
|||||||
color = Color.Transparent,
|
color = Color.Transparent,
|
||||||
tonalElevation = 0.dp,
|
tonalElevation = 0.dp,
|
||||||
) {
|
) {
|
||||||
val isRtl = remember(message.content) { BidiUtils.isRtlText(message.content) }
|
val isRtl = remember(displayContent) { BidiUtils.isRtlText(displayContent) }
|
||||||
val bubbleDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
val bubbleDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||||
CompositionLocalProvider(LocalLayoutDirection provides bubbleDirection) {
|
CompositionLocalProvider(LocalLayoutDirection provides bubbleDirection) {
|
||||||
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
|
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
|
||||||
@@ -183,23 +204,6 @@ fun UserBubble(
|
|||||||
canSave = canSaveAttachment,
|
canSave = canSaveAttachment,
|
||||||
onImageClick = onImageClick,
|
onImageClick = onImageClick,
|
||||||
)
|
)
|
||||||
// #1427: keep the live text and media visible with an honest delivery label.
|
|
||||||
if (pendingSendState == PendingSendState.SENDING ||
|
|
||||||
pendingSendState == PendingSendState.ACCEPTED
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(
|
|
||||||
if (pendingSendState == PendingSendState.SENDING) {
|
|
||||||
R.string.chat_pending_sending
|
|
||||||
} else {
|
|
||||||
R.string.chat_send_accepted
|
|
||||||
},
|
|
||||||
),
|
|
||||||
color = userBubbleTextColor,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
modifier = Modifier.testTag("user_send_status_${message.id}"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (!message.isStreaming) {
|
if (!message.isStreaming) {
|
||||||
FlowRow(
|
FlowRow(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -236,6 +240,11 @@ fun UserBubble(
|
|||||||
color = userBubbleTextColor.copy(alpha = 0.6f),
|
color = userBubbleTextColor.copy(alpha = 0.6f),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
)
|
)
|
||||||
|
DeliveryChecks(
|
||||||
|
state = deliveryState,
|
||||||
|
tint = userBubbleTextColor,
|
||||||
|
messageId = message.id,
|
||||||
|
)
|
||||||
if (messageStatsEnabled && showUserMessageTokens &&
|
if (messageStatsEnabled && showUserMessageTokens &&
|
||||||
message.tokenCount != null && message.tokenCount > 0
|
message.tokenCount != null && message.tokenCount > 0
|
||||||
) {
|
) {
|
||||||
@@ -268,3 +277,24 @@ fun UserBubble(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** WhatsApp-style delivery ticks: one dim check while sending, two once the server has the prompt. */
|
||||||
|
private enum class DeliveryState { SENT, DELIVERED }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeliveryChecks(
|
||||||
|
state: DeliveryState?,
|
||||||
|
tint: Color,
|
||||||
|
messageId: String,
|
||||||
|
) {
|
||||||
|
if (state == null) return
|
||||||
|
val delivered = state == DeliveryState.DELIVERED
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = if (delivered) Icons.Filled.DoneAll else Icons.Filled.Done,
|
||||||
|
contentDescription =
|
||||||
|
stringResource(if (delivered) R.string.chat_send_accepted else R.string.chat_pending_sending),
|
||||||
|
modifier = Modifier.size(14.dp).testTag("user_send_status_$messageId"),
|
||||||
|
tint = tint.copy(alpha = if (delivered) 1f else 0.6f),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
|
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses user-authored or server-persisted `@image:` directives from message content into
|
||||||
|
* gateway-backed [Attachment]s.
|
||||||
|
*
|
||||||
|
* Requirements:
|
||||||
|
* - Recognizes whole trimmed lines `@image:/absolute/path` (spaces in path allowed).
|
||||||
|
* - Ignores inline prose refs (e.g. "look at @image:/path in the middle of a sentence").
|
||||||
|
* - Ignores empty or relative paths (must start with '/').
|
||||||
|
* - Preserves unique paths across lines (deduplicates repeated references to the same path).
|
||||||
|
* - Resolves each path via [mediaUrl]; drops attachments where [mediaUrl] returns null.
|
||||||
|
* - Constructs [Attachment]s with source = [AttachmentSource.GATEWAY], resolved uri/gatewayUrl,
|
||||||
|
* size = 0, name = [mediaNameFromPath], mimeType = [mediaMimeForPath].
|
||||||
|
*/
|
||||||
|
internal fun userImageAttachments(
|
||||||
|
content: String,
|
||||||
|
mediaUrl: (String) -> String?,
|
||||||
|
): List<Attachment> {
|
||||||
|
if (!content.contains("@image:")) return emptyList()
|
||||||
|
|
||||||
|
val seenPaths = mutableSetOf<String>()
|
||||||
|
val attachments = mutableListOf<Attachment>()
|
||||||
|
|
||||||
|
for (rawLine in content.lines()) {
|
||||||
|
val line = rawLine.trim()
|
||||||
|
if (!line.startsWith("@image:")) continue
|
||||||
|
val path = line.removePrefix("@image:").trim()
|
||||||
|
if (!path.startsWith("/")) continue
|
||||||
|
if (!seenPaths.add(path)) continue
|
||||||
|
|
||||||
|
val url = mediaUrl(path) ?: continue
|
||||||
|
attachments.add(
|
||||||
|
Attachment(
|
||||||
|
uri = url,
|
||||||
|
name = mediaNameFromPath(path),
|
||||||
|
mimeType = mediaMimeForPath(path),
|
||||||
|
size = 0,
|
||||||
|
gatewayUrl = url,
|
||||||
|
source = AttachmentSource.GATEWAY,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return attachments
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hides whole-line `@image:/abs/path` refs from bubble text once they render as attachments. */
|
||||||
|
internal fun hideImageRefLines(content: String): String {
|
||||||
|
if (!content.contains("@image:")) return content
|
||||||
|
return content
|
||||||
|
.lines()
|
||||||
|
.filterNot { line ->
|
||||||
|
line.trim().let {
|
||||||
|
it.startsWith("@image:") &&
|
||||||
|
it.removePrefix("@image:").trim().startsWith("/")
|
||||||
|
}
|
||||||
|
}.joinToString("\n")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
@@ -10,8 +10,11 @@ import androidx.compose.animation.shrinkVertically
|
|||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.content.ReceiveContentListener
|
||||||
|
import androidx.compose.foundation.content.contentReceiver
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -32,6 +35,9 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.input.InputTransformation
|
||||||
|
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||||
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
@@ -39,6 +45,7 @@ import androidx.compose.material.icons.filled.Lock
|
|||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -64,8 +71,9 @@ import androidx.compose.ui.platform.LocalLayoutDirection
|
|||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextDirection
|
import androidx.compose.ui.text.style.TextDirection
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@@ -86,9 +94,9 @@ import com.m57.hermescontrol.util.BidiUtils
|
|||||||
* controls row (attach, model/reasoning pill, mic, send) inside it below.
|
* controls row (attach, model/reasoning pill, mic, send) inside it below.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
fun ChatInputBar(
|
fun ChatInputBar(
|
||||||
inputFieldValue: TextFieldValue,
|
inputState: TextFieldState,
|
||||||
onInputChange: (TextFieldValue) -> Unit,
|
|
||||||
onSend: () -> Unit,
|
onSend: () -> Unit,
|
||||||
onMicTap: () -> Unit,
|
onMicTap: () -> Unit,
|
||||||
isListening: Boolean,
|
isListening: Boolean,
|
||||||
@@ -127,15 +135,18 @@ fun ChatInputBar(
|
|||||||
isRecordingVoice: Boolean = false,
|
isRecordingVoice: Boolean = false,
|
||||||
amplitudeProvider: () -> Float = { 0f },
|
amplitudeProvider: () -> Float = { 0f },
|
||||||
onStopGeneration: () -> Unit = {},
|
onStopGeneration: () -> Unit = {},
|
||||||
|
receiveContentListener: ReceiveContentListener? = null,
|
||||||
|
isReceivingContent: Boolean = false,
|
||||||
) {
|
) {
|
||||||
|
val inputText = inputState.text.toString()
|
||||||
// Allow sending while the agent is mid-turn or awaiting approval: the
|
// Allow sending while the agent is mid-turn or awaiting approval: the
|
||||||
// gateway's prompt.submit busy-input policy queues it as the next turn
|
// gateway's prompt.submit busy-input policy queues it as the next turn
|
||||||
// (tui_gateway/server.py:_handle_busy_submit), so the message is never
|
// (tui_gateway/server.py:_handle_busy_submit), so the message is never
|
||||||
// dropped. Slash commands were already allowed; regular prompts now are too.
|
// dropped. Slash commands were already allowed; regular prompts now are too.
|
||||||
val canSend =
|
val canSend =
|
||||||
pendingReasoningLevel == null &&
|
!isReceivingContent && pendingReasoningLevel == null &&
|
||||||
ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected, isSessionReady)
|
ChatInputPolicy.canSend(inputText, pendingAttachments, isConnected, isSessionReady)
|
||||||
val hasDraft = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty()
|
val hasDraft = inputText.isNotBlank() || pendingAttachments.isNotEmpty()
|
||||||
|
|
||||||
// Attachment tray state
|
// Attachment tray state
|
||||||
var showAttachmentTray by remember { mutableStateOf(false) }
|
var showAttachmentTray by remember { mutableStateOf(false) }
|
||||||
@@ -232,6 +243,16 @@ fun ChatInputBar(
|
|||||||
border = BorderStroke(width = 1.dp, color = palette.cardBorder),
|
border = BorderStroke(width = 1.dp, color = palette.cardBorder),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(top = 8.dp, bottom = 10.dp)) {
|
Column(modifier = Modifier.padding(top = 8.dp, bottom = 10.dp)) {
|
||||||
|
if (isReceivingContent) {
|
||||||
|
val loadingLabel = stringResource(R.string.chat_image_paste_loading)
|
||||||
|
LinearProgressIndicator(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag("chat_image_paste_progress")
|
||||||
|
.semantics { contentDescription = loadingLabel },
|
||||||
|
)
|
||||||
|
}
|
||||||
// Commands hidden from the suggestion menu — desktop/CLI-only and
|
// Commands hidden from the suggestion menu — desktop/CLI-only and
|
||||||
// TUI-only commands that don't function on mobile (issue #574).
|
// TUI-only commands that don't function on mobile (issue #574).
|
||||||
// Single source of truth: CommandBlocklist.UNSUPPORTED, which is
|
// Single source of truth: CommandBlocklist.UNSUPPORTED, which is
|
||||||
@@ -245,13 +266,13 @@ fun ChatInputBar(
|
|||||||
.filter { it.lowercase() !in hiddenSlashDisplay }
|
.filter { it.lowercase() !in hiddenSlashDisplay }
|
||||||
|
|
||||||
androidx.compose.animation.AnimatedVisibility(
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
visible = inputFieldValue.text.startsWith("/") && !inputFieldValue.text.contains(" "),
|
visible = inputText.startsWith("/") && !inputText.contains(" "),
|
||||||
enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(),
|
enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(),
|
||||||
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(),
|
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(),
|
||||||
) {
|
) {
|
||||||
val filteredCommands =
|
val filteredCommands =
|
||||||
ChatInputPolicy.sortSlashSuggestions(
|
ChatInputPolicy.sortSlashSuggestions(
|
||||||
commandNames.filter { it.startsWith(inputFieldValue.text, ignoreCase = true) },
|
commandNames.filter { it.startsWith(inputText, ignoreCase = true) },
|
||||||
slashUsageCounts,
|
slashUsageCounts,
|
||||||
)
|
)
|
||||||
if (filteredCommands.isNotEmpty()) {
|
if (filteredCommands.isNotEmpty()) {
|
||||||
@@ -280,7 +301,7 @@ fun ChatInputBar(
|
|||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = { onInputChange(ChatInputPolicy.commandFieldValue(cmd)) },
|
onClick = { inputState.replaceComposerDraft(cmd) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,7 +362,7 @@ fun ChatInputBar(
|
|||||||
stringResource(R.string.chat_input_placeholder_not_connected)
|
stringResource(R.string.chat_input_placeholder_not_connected)
|
||||||
}
|
}
|
||||||
|
|
||||||
ChatInputPolicy.showQueuePlaceholder(inputFieldValue.text, isAgentTyping) -> {
|
ChatInputPolicy.showQueuePlaceholder(inputText, isAgentTyping) -> {
|
||||||
stringResource(R.string.chat_input_placeholder_queue)
|
stringResource(R.string.chat_input_placeholder_queue)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,9 +377,9 @@ fun ChatInputBar(
|
|||||||
|
|
||||||
val ambientLayoutDirection = LocalLayoutDirection.current
|
val ambientLayoutDirection = LocalLayoutDirection.current
|
||||||
val inputLayoutDirection =
|
val inputLayoutDirection =
|
||||||
remember(inputFieldValue.text, ambientLayoutDirection) {
|
remember(inputText, ambientLayoutDirection) {
|
||||||
BidiUtils.resolveLayoutDirection(
|
BidiUtils.resolveLayoutDirection(
|
||||||
inputFieldValue.text,
|
inputText,
|
||||||
fallback = ambientLayoutDirection,
|
fallback = ambientLayoutDirection,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -381,8 +402,7 @@ fun ChatInputBar(
|
|||||||
.heightIn(min = 42.dp),
|
.heightIn(min = 42.dp),
|
||||||
) {
|
) {
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = inputFieldValue,
|
state = inputState,
|
||||||
onValueChange = { if (!isRecordingVoice) onInputChange(it) },
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -392,18 +412,28 @@ fun ChatInputBar(
|
|||||||
.focusRequester(inputFocusRequester)
|
.focusRequester(inputFocusRequester)
|
||||||
.onFocusChanged { focusState ->
|
.onFocusChanged { focusState ->
|
||||||
inputFocused = focusState.isFocused
|
inputFocused = focusState.isFocused
|
||||||
}.testTag("chat_input"),
|
}.testTag("chat_input")
|
||||||
|
.then(
|
||||||
|
if (receiveContentListener != null) {
|
||||||
|
Modifier.contentReceiver(receiveContentListener)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
),
|
||||||
enabled = isConnected,
|
enabled = isConnected,
|
||||||
|
inputTransformation =
|
||||||
|
InputTransformation {
|
||||||
|
if (isRecordingVoice) revertAllChanges()
|
||||||
|
},
|
||||||
textStyle =
|
textStyle =
|
||||||
MaterialTheme.typography.bodyLarge.copy(
|
MaterialTheme.typography.bodyLarge.copy(
|
||||||
color = palette.text,
|
color = palette.text,
|
||||||
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||||
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
|
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||||
),
|
),
|
||||||
singleLine = false,
|
lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 8),
|
||||||
maxLines = 8,
|
|
||||||
cursorBrush = SolidColor(palette.text),
|
cursorBrush = SolidColor(palette.text),
|
||||||
decorationBox = { innerTextField ->
|
decorator = { innerTextField ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
contentAlignment =
|
contentAlignment =
|
||||||
@@ -416,7 +446,7 @@ fun ChatInputBar(
|
|||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalLayoutDirection provides inputLayoutDirection,
|
LocalLayoutDirection provides inputLayoutDirection,
|
||||||
) {
|
) {
|
||||||
if (inputFieldValue.text.isEmpty() && !isRecordingVoice) {
|
if (inputText.isEmpty() && !isRecordingVoice) {
|
||||||
Text(
|
Text(
|
||||||
text = placeholderText,
|
text = placeholderText,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
|
||||||
|
|
||||||
|
/** Apply an intentional external draft replacement, never a value mirror during ordinary typing. */
|
||||||
|
internal fun TextFieldState.replaceComposerDraft(text: String) {
|
||||||
|
val replacement = ChatInputPolicy.commandFieldValue(text)
|
||||||
|
edit {
|
||||||
|
replace(0, length, replacement.text)
|
||||||
|
selection = replacement.selection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recheck the live draft when a rejected send is returned; it may have changed since dispatch. */
|
||||||
|
internal fun TextFieldState.restoreRejectedComposerDraft(rejectedText: String) {
|
||||||
|
val current = TextFieldValue(text.toString(), selection)
|
||||||
|
val restored = ChatInputPolicy.restoreRejectedText(rejectedText, current)
|
||||||
|
edit {
|
||||||
|
replace(0, length, restored.text)
|
||||||
|
selection = restored.selection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Speech results arrive asynchronously: append to the draft as it exists at result time. */
|
||||||
|
internal fun TextFieldState.appendSpeechComposerDraft(spokenText: String) {
|
||||||
|
if (spokenText.isBlank()) return
|
||||||
|
val current = text.toString()
|
||||||
|
replaceComposerDraft(if (current.isBlank()) spokenText else "$current $spokenText")
|
||||||
|
}
|
||||||
+195
@@ -0,0 +1,195 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.content.MediaType
|
||||||
|
import androidx.compose.foundation.content.ReceiveContentListener
|
||||||
|
import androidx.compose.foundation.content.TransferableContent
|
||||||
|
import androidx.compose.foundation.content.consume
|
||||||
|
import androidx.compose.foundation.content.hasMediaType
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalResources
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
|
import com.m57.hermescontrol.ui.chat.ChatAttachmentTarget
|
||||||
|
import com.m57.hermescontrol.ui.chat.ChatPastedImageStore
|
||||||
|
import com.m57.hermescontrol.ui.chat.ChatViewModel
|
||||||
|
import com.m57.hermescontrol.ui.chat.MAX_CHAT_ATTACHMENT_BYTES
|
||||||
|
import com.m57.hermescontrol.ui.chat.PastedImage
|
||||||
|
import com.m57.hermescontrol.ui.chat.PastedImageTooLargeException
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
internal const val MAX_PASTED_IMAGE_COUNT = 8
|
||||||
|
internal const val MAX_PASTED_IMAGE_BATCH_BYTES = 20L * 1024L * 1024L
|
||||||
|
|
||||||
|
private class PastedImageBatchLimitException : IOException("Pasted image batch exceeds its limit")
|
||||||
|
|
||||||
|
/** Receives explicit paste/IME commits only; never reads or observes the system clipboard. */
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
internal class ChatImagePasteController(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val importImage: suspend (Uri, Long) -> PastedImage?,
|
||||||
|
private val captureTarget: () -> ChatAttachmentTarget?,
|
||||||
|
private val commit: (ChatAttachmentTarget, List<Attachment>) -> Boolean,
|
||||||
|
private val onError: (Int) -> Unit,
|
||||||
|
private val isImageUri: (Uri) -> Boolean = { false },
|
||||||
|
) : ReceiveContentListener {
|
||||||
|
var isReceiving by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var closed = false
|
||||||
|
private var job: Job? = null
|
||||||
|
private val retainedPayloads = mutableSetOf<Any>()
|
||||||
|
|
||||||
|
override fun onReceive(transferableContent: TransferableContent): TransferableContent? {
|
||||||
|
if (!transferableContent.hasMediaType(MediaType.Image)) return transferableContent
|
||||||
|
// Drag grants have a different lifecycle; this feature handles paste and IME commits only.
|
||||||
|
if (transferableContent.source != TransferableContent.Source.Clipboard &&
|
||||||
|
transferableContent.source != TransferableContent.Source.Keyboard
|
||||||
|
) {
|
||||||
|
return transferableContent
|
||||||
|
}
|
||||||
|
val clip = transferableContent.clipEntry.clipData
|
||||||
|
val uris = ArrayList<Uri>(MAX_PASTED_IMAGE_COUNT + 1)
|
||||||
|
for (index in 0 until clip.itemCount) {
|
||||||
|
val uri = clip.getItemAt(index).uri
|
||||||
|
if (uri?.scheme == "content" && isImageUri(uri)) {
|
||||||
|
uris.add(uri)
|
||||||
|
if (uris.size > MAX_PASTED_IMAGE_COUNT) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!receiveImages(uris, transferableContent)) return transferableContent
|
||||||
|
// Only accepted image candidates are consumed. Text and non-image URIs
|
||||||
|
// remain available to the ordinary text field/other receivers.
|
||||||
|
val accepted = uris.toSet()
|
||||||
|
// An item can carry both image URI and text. Keep that item for the text
|
||||||
|
// field after importing its image; consuming it would drop its text.
|
||||||
|
return transferableContent.consume { it.uri in accepted && it.text == null && it.htmlText == null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [payload] strongly retains Compose's temporary InputContentInfo permission until IO ends. */
|
||||||
|
internal fun receiveImages(
|
||||||
|
uris: List<Uri>,
|
||||||
|
payload: Any,
|
||||||
|
): Boolean {
|
||||||
|
if (closed || !scope.isActive || uris.isEmpty()) return false
|
||||||
|
if (isReceiving) {
|
||||||
|
onError(R.string.chat_image_paste_busy)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (uris.size > MAX_PASTED_IMAGE_COUNT) {
|
||||||
|
onError(R.string.chat_image_paste_batch_limit)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val target = captureTarget()
|
||||||
|
if (target == null) {
|
||||||
|
onError(R.string.chat_session_not_ready)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
isReceiving = true
|
||||||
|
retainedPayloads.add(payload)
|
||||||
|
job =
|
||||||
|
scope.launch {
|
||||||
|
val images = mutableListOf<PastedImage>()
|
||||||
|
var remainingBytes = MAX_PASTED_IMAGE_BATCH_BYTES
|
||||||
|
var committed = false
|
||||||
|
try {
|
||||||
|
for (uri in uris) {
|
||||||
|
if (remainingBytes == 0L) throw PastedImageBatchLimitException()
|
||||||
|
val cap = minOf(remainingBytes, MAX_CHAT_ATTACHMENT_BYTES)
|
||||||
|
val image = importImage(uri, cap) ?: throw IOException("Unreadable pasted image")
|
||||||
|
images += image
|
||||||
|
remainingBytes -= image.file.length()
|
||||||
|
if (remainingBytes < 0L) throw PastedImageBatchLimitException()
|
||||||
|
}
|
||||||
|
currentCoroutineContext().ensureActive()
|
||||||
|
if (!closed && captureTarget() == target) {
|
||||||
|
committed = commit(target, images.map { it.attachment })
|
||||||
|
}
|
||||||
|
} catch (cancelled: CancellationException) {
|
||||||
|
throw cancelled
|
||||||
|
} catch (failure: Exception) {
|
||||||
|
if (!closed && captureTarget() == target) {
|
||||||
|
onError(
|
||||||
|
if (failure is PastedImageBatchLimitException ||
|
||||||
|
(failure is PastedImageTooLargeException && remainingBytes < MAX_CHAT_ATTACHMENT_BYTES)
|
||||||
|
) {
|
||||||
|
R.string.chat_image_paste_batch_limit
|
||||||
|
} else if (failure is PastedImageTooLargeException) {
|
||||||
|
R.string.chat_image_paste_too_large
|
||||||
|
} else {
|
||||||
|
R.string.chat_image_paste_failed
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!committed) {
|
||||||
|
withContext(NonCancellable + Dispatchers.IO) { images.forEach { it.discard() } }
|
||||||
|
}
|
||||||
|
retainedPayloads.remove(payload)
|
||||||
|
isReceiving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Covers a scope cancelled before the launched coroutine ever starts.
|
||||||
|
job?.invokeOnCompletion { retainedPayloads.remove(payload) }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
closed = true
|
||||||
|
job?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
internal fun rememberChatImagePaste(
|
||||||
|
viewModel: ChatViewModel,
|
||||||
|
onShowMessage: (String) -> Unit,
|
||||||
|
): ChatImagePasteController {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val resources by rememberUpdatedState(LocalResources.current)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val showMessage by rememberUpdatedState(onShowMessage)
|
||||||
|
val controller =
|
||||||
|
remember(viewModel, context, scope) {
|
||||||
|
val store = ChatPastedImageStore(context.applicationContext)
|
||||||
|
ChatImagePasteController(
|
||||||
|
scope = scope,
|
||||||
|
importImage = store::importImage,
|
||||||
|
isImageUri = { uri ->
|
||||||
|
try {
|
||||||
|
context.contentResolver.getType(uri)?.let { type ->
|
||||||
|
type.startsWith("image/", ignoreCase = true) && type.length > "image/".length
|
||||||
|
} == true
|
||||||
|
} catch (_: RuntimeException) {
|
||||||
|
// Provider failures must never consume an unclassified URI.
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
captureTarget = viewModel::captureAttachmentTarget,
|
||||||
|
commit = viewModel::addPastedAttachments,
|
||||||
|
onError = { showMessage(resources.getString(it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DisposableEffect(controller) { onDispose { controller.close() } }
|
||||||
|
return controller
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import android.speech.RecognizerIntent
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -21,13 +22,11 @@ import androidx.compose.runtime.rememberUpdatedState
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
|
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.model.Attachment
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
|
|
||||||
import com.m57.hermescontrol.ui.chat.SpeechInputHelper
|
import com.m57.hermescontrol.ui.chat.SpeechInputHelper
|
||||||
import com.m57.hermescontrol.ui.chat.VoiceNoteRecorder
|
import com.m57.hermescontrol.ui.chat.VoiceNoteRecorder
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -55,8 +54,7 @@ class ChatMediaLaunchers(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberChatMediaLaunchers(
|
fun rememberChatMediaLaunchers(
|
||||||
inputFieldValue: TextFieldValue,
|
inputState: TextFieldState,
|
||||||
onInputFieldValueChange: (TextFieldValue) -> Unit,
|
|
||||||
onAddAttachment: (uri: String, name: String, mimeType: String, size: Long) -> Unit,
|
onAddAttachment: (uri: String, name: String, mimeType: String, size: Long) -> Unit,
|
||||||
onAddAttachments: (List<Attachment>) -> Unit,
|
onAddAttachments: (List<Attachment>) -> Unit,
|
||||||
onVoiceNoteRecorded: (file: File) -> Unit,
|
onVoiceNoteRecorded: (file: File) -> Unit,
|
||||||
@@ -68,8 +66,7 @@ fun rememberChatMediaLaunchers(
|
|||||||
var isListening by remember { mutableStateOf(false) }
|
var isListening by remember { mutableStateOf(false) }
|
||||||
var pendingCameraUri by remember { mutableStateOf<Uri?>(null) }
|
var pendingCameraUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
|
|
||||||
val currentInputFieldValue by rememberUpdatedState(inputFieldValue)
|
val currentInputState by rememberUpdatedState(inputState)
|
||||||
val currentOnInputFieldValueChange by rememberUpdatedState(onInputFieldValueChange)
|
|
||||||
val currentOnAddAttachment by rememberUpdatedState(onAddAttachment)
|
val currentOnAddAttachment by rememberUpdatedState(onAddAttachment)
|
||||||
val currentOnAddAttachments by rememberUpdatedState(onAddAttachments)
|
val currentOnAddAttachments by rememberUpdatedState(onAddAttachments)
|
||||||
val currentOnVoiceNoteRecorded by rememberUpdatedState(onVoiceNoteRecorded)
|
val currentOnVoiceNoteRecorded by rememberUpdatedState(onVoiceNoteRecorded)
|
||||||
@@ -134,14 +131,7 @@ fun rememberChatMediaLaunchers(
|
|||||||
?.firstOrNull()
|
?.firstOrNull()
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
if (spokenText.isNotBlank()) {
|
if (spokenText.isNotBlank()) {
|
||||||
val currentText = currentInputFieldValue.text
|
currentInputState.appendSpeechComposerDraft(spokenText)
|
||||||
val merged =
|
|
||||||
if (currentText.isBlank()) {
|
|
||||||
spokenText
|
|
||||||
} else {
|
|
||||||
"$currentText $spokenText"
|
|
||||||
}
|
|
||||||
currentOnInputFieldValueChange(ChatInputPolicy.commandFieldValue(merged))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,23 @@ private object FullBleedContentType {
|
|||||||
const val SYSTEM_EVENT: String = "system_event"
|
const val SYSTEM_EVENT: String = "system_event"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Only show the placeholder when there are no messages or renderable live tail items. */
|
||||||
|
internal fun shouldShowChatEmptyState(
|
||||||
|
transcript: TranscriptUiState,
|
||||||
|
hasReplyError: Boolean,
|
||||||
|
): Boolean =
|
||||||
|
transcript.messages.isEmpty() &&
|
||||||
|
transcript.streamingState.streamingMessage == null &&
|
||||||
|
!transcript.isLoading &&
|
||||||
|
!transcript.isAgentTyping &&
|
||||||
|
!hasReplyError &&
|
||||||
|
transcript.clarifyRequest == null &&
|
||||||
|
transcript.vaultUnlockPrompt == null &&
|
||||||
|
transcript.vaultSaveLoginPrompt == null &&
|
||||||
|
transcript.vaultCodePrompt == null &&
|
||||||
|
!transcript.isCompressing &&
|
||||||
|
transcript.compressionStatus == null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The chat message list for FULL-BLEED style (issue #866) — the single chat
|
* The chat message list for FULL-BLEED style (issue #866) — the single chat
|
||||||
* surface since the bubble renderer was removed. User messages keep their
|
* surface since the bubble renderer was removed. User messages keep their
|
||||||
@@ -105,7 +122,7 @@ fun FullBleedChatList(
|
|||||||
val isCompressing = transcript.isCompressing
|
val isCompressing = transcript.isCompressing
|
||||||
val compressionStatus = transcript.compressionStatus
|
val compressionStatus = transcript.compressionStatus
|
||||||
val speakingMessageId = transcript.speakingMessageId
|
val speakingMessageId = transcript.speakingMessageId
|
||||||
if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) {
|
if (shouldShowChatEmptyState(transcript, hasReplyError = replyErrorContent != null)) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ internal fun ChatMessage.isSyntheticSystemRow(): Boolean =
|
|||||||
displayKind == null &&
|
displayKind == null &&
|
||||||
content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER)
|
content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER)
|
||||||
|
|
||||||
|
private const val HIDDEN_TOOL_NAME = "react_to_message"
|
||||||
|
|
||||||
|
/** Tool rows that never get a transcript bubble. */
|
||||||
|
internal fun ChatMessage.isHiddenTool(): Boolean = role == MessageRole.TOOL && toolName == HIDDEN_TOOL_NAME
|
||||||
|
|
||||||
internal fun ChatMessage.isTimelineMarker(): Boolean = displayKind != null && displayKind !in DisplayKind.nonMarkerKinds
|
internal fun ChatMessage.isTimelineMarker(): Boolean = displayKind != null && displayKind !in DisplayKind.nonMarkerKinds
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,6 +113,10 @@ fun groupIntoTurns(messages: List<ChatMessage>): List<ChatTurn> {
|
|||||||
agentEntries += AgentEntry.Prose(message)
|
agentEntries += AgentEntry.Prose(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message.isHiddenTool() -> {
|
||||||
|
// Reaction tool calls have no useful bubble; the reaction itself is the feedback.
|
||||||
|
}
|
||||||
|
|
||||||
MessageRole.TOOL == message.role -> {
|
MessageRole.TOOL == message.role -> {
|
||||||
agentEntries += AgentEntry.ToolRow(message)
|
agentEntries += AgentEntry.ToolRow(message)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="chat_image_paste_loading">Adding pasted images…</string>
|
||||||
|
<string name="chat_image_paste_failed">Could not paste the image. Check access and try again.</string>
|
||||||
|
<string name="chat_image_paste_too_large">Image exceeds the 10 MB attachment limit.</string>
|
||||||
|
<string name="chat_image_paste_batch_limit">Paste up to 8 images totaling no more than 20 MB.</string>
|
||||||
|
<string name="chat_image_paste_busy">Wait for the images to finish loading before pasting again.</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package com.m57.hermescontrol.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonArray
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import kotlinx.serialization.json.putJsonObject
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression tests for SessionMessage multipart content and display_content text extraction.
|
||||||
|
*
|
||||||
|
* Contract requirements (Issue #1432):
|
||||||
|
* - [SessionMessage.contentText] and [SessionMessage.displayContentText] extract recognized text parts
|
||||||
|
* of multipart JsonArray in order.
|
||||||
|
* - Recognized text parts: {"type": "text", "text": "..."} or {"text": "..."}.
|
||||||
|
* - Omit "image_url", inline base64 ("data:image/..."), and unknown/malformed parts rather than JSON dumping.
|
||||||
|
* - Null content -> empty string ("").
|
||||||
|
* - Absent display_content (null) -> null.
|
||||||
|
* - Explicit empty string display wins ("" -> "").
|
||||||
|
* - Plain string primitives are retained unchanged (including very long plain text).
|
||||||
|
* - Avoid imposing changes on unknown object tool content (non-array objects dump or retain their object representation).
|
||||||
|
*/
|
||||||
|
class SessionMessageMultipartTest {
|
||||||
|
@Test
|
||||||
|
fun contentText_whenContentIsNull_returnsEmptyString() {
|
||||||
|
val message = SessionMessage(content = null)
|
||||||
|
assertEquals("", message.contentText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun displayContentText_whenDisplayContentIsNull_returnsNull() {
|
||||||
|
val message = SessionMessage(display_content = null)
|
||||||
|
assertNull(message.displayContentText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun displayContentText_whenDisplayContentIsExplicitEmptyString_returnsEmptyString() {
|
||||||
|
val message = SessionMessage(display_content = JsonPrimitive(""))
|
||||||
|
assertEquals("", message.displayContentText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun contentText_whenContentIsPrimitiveString_retainsUnchanged() {
|
||||||
|
val text = "Hello world, this is a plain message turn."
|
||||||
|
val message = SessionMessage(content = JsonPrimitive(text))
|
||||||
|
assertEquals(text, message.contentText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun contentText_whenContentIsLongPlainText_retainsEntireStringUnchanged() {
|
||||||
|
val longText = "A".repeat(100_000)
|
||||||
|
val message = SessionMessage(content = JsonPrimitive(longText))
|
||||||
|
assertEquals(longText, message.contentText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun contentText_whenMultipartWith748kBase64AndImageCaption_extractsCaptionOnly() {
|
||||||
|
val largeBase64 = "data:image/png;base64," + "iVBORw0KGgoAAAANSUhEUgAA".repeat(31_000) // ~748k chars
|
||||||
|
val caption = "Check out this screenshot\n@image:/opt/data/images/screen.png"
|
||||||
|
|
||||||
|
val multipart =
|
||||||
|
buildJsonArray {
|
||||||
|
add(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", "image_url")
|
||||||
|
putJsonObject("image_url") {
|
||||||
|
put("url", largeBase64)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", "text")
|
||||||
|
put("text", caption)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val message = SessionMessage(content = multipart)
|
||||||
|
assertEquals(caption, message.contentText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun contentText_whenMultipleTextPartsInArray_extractsInOrder() {
|
||||||
|
val multipart =
|
||||||
|
buildJsonArray {
|
||||||
|
add(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", "text")
|
||||||
|
put("text", "First paragraph.")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", "image_url")
|
||||||
|
putJsonObject("image_url") {
|
||||||
|
put("url", "https://example.com/photo.png")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", "text")
|
||||||
|
put("text", "Second paragraph.")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
buildJsonObject {
|
||||||
|
// Shorthand text block without explicit type
|
||||||
|
put("text", "Third paragraph.")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val message = SessionMessage(content = multipart)
|
||||||
|
val expected = "First paragraph.\nSecond paragraph.\nThird paragraph."
|
||||||
|
assertEquals(expected, message.contentText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun contentText_whenMultipartContainsMalformedOrUnknownElements_omitsThemWithoutJsonDumping() {
|
||||||
|
val multipart =
|
||||||
|
buildJsonArray {
|
||||||
|
add(JsonPrimitive(12345))
|
||||||
|
add(JsonPrimitive(true))
|
||||||
|
add(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", "unknown_blob")
|
||||||
|
put("raw", "skip_me")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", "text")
|
||||||
|
put("invalid_key", "no text property here")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", "text")
|
||||||
|
put("text", "Valid extracted text.")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", "image")
|
||||||
|
put("source", "base64data")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val message = SessionMessage(content = multipart)
|
||||||
|
assertEquals("Valid extracted text.", message.contentText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun contentText_whenMultipartHasNoTextParts_returnsEmptyString() {
|
||||||
|
val multipart =
|
||||||
|
buildJsonArray {
|
||||||
|
add(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", "image_url")
|
||||||
|
putJsonObject("image_url") {
|
||||||
|
put("url", "https://example.com/icon.png")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", "audio")
|
||||||
|
put("data", "abc123audio")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val message = SessionMessage(content = multipart)
|
||||||
|
assertEquals("", message.contentText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun displayContentText_whenProjectedDisplayContentIsMultipartArray_extractsTextParts() {
|
||||||
|
val projectedDisplayArray =
|
||||||
|
buildJsonArray {
|
||||||
|
add(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", "text")
|
||||||
|
put("text", "Summary of conversation")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", "image_url")
|
||||||
|
putJsonObject("image_url") {
|
||||||
|
put("url", "data:image/jpeg;base64,12345")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", "text")
|
||||||
|
put("text", "Key takeaways follow.")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val message =
|
||||||
|
SessionMessage(
|
||||||
|
content = JsonPrimitive("raw fallback content"),
|
||||||
|
display_content = projectedDisplayArray,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("Summary of conversation\nKey takeaways follow.", message.displayContentText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun contentText_whenContentIsJsonObjectToolCallOrResult_doesNotImposeMultipartChanges() {
|
||||||
|
val toolObject =
|
||||||
|
buildJsonObject {
|
||||||
|
put("status", "success")
|
||||||
|
put("output", "tool execution output")
|
||||||
|
}
|
||||||
|
|
||||||
|
val message = SessionMessage(content = toolObject)
|
||||||
|
// Ensure non-array JsonObject continues existing behavior (e.g. toString/not mangled as multipart array)
|
||||||
|
assertEquals(toolObject.toString(), message.contentText)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,15 +71,21 @@ class GatewayContractFramesTest {
|
|||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
|
try {
|
||||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||||
HermesWsClient.releaseBackgroundConnectionLease()
|
HermesWsClient.releaseBackgroundConnectionLease()
|
||||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||||
runBlocking {
|
runBlocking {
|
||||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
|
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
|
||||||
}
|
}
|
||||||
runCatching { mockWebServer.shutdown() }
|
} finally {
|
||||||
|
try {
|
||||||
|
mockWebServer.shutdown()
|
||||||
|
} finally {
|
||||||
unmockkAll()
|
unmockkAll()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Connect, run [trigger], and return the first frame the server sees for [method]. */
|
/** Connect, run [trigger], and return the first frame the server sees for [method]. */
|
||||||
private fun captureFrame(
|
private fun captureFrame(
|
||||||
@@ -91,7 +97,7 @@ class GatewayContractFramesTest {
|
|||||||
val captured = CountDownLatch(1)
|
val captured = CountDownLatch(1)
|
||||||
val frame = AtomicReference<JsonObject?>(null)
|
val frame = AtomicReference<JsonObject?>(null)
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ class GatewayContractTest {
|
|||||||
// ChatViewModel, GroupChatViewModel
|
// ChatViewModel, GroupChatViewModel
|
||||||
"session.create" to setOf("profile", "title", "source", "hidden"),
|
"session.create" to setOf("profile", "title", "source", "hidden"),
|
||||||
// ChatViewModel.resumeSession, NotificationReplyReceiver
|
// ChatViewModel.resumeSession, NotificationReplyReceiver
|
||||||
"session.resume" to setOf("session_id", "omit_messages"),
|
"session.resume" to setOf("session_id", "source", "omit_messages"),
|
||||||
// ChatViewModel, GroupChatViewModel
|
// ChatViewModel, GroupChatViewModel
|
||||||
"session.interrupt" to setOf("session_id"),
|
"session.interrupt" to setOf("session_id"),
|
||||||
)
|
)
|
||||||
|
|||||||
+10
-4
@@ -76,6 +76,7 @@ class HermesWsClientSessionControlTest {
|
|||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
|
try {
|
||||||
AuthManager.resetAuthStateForTest()
|
AuthManager.resetAuthStateForTest()
|
||||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||||
HermesWsClient.releaseBackgroundConnectionLease()
|
HermesWsClient.releaseBackgroundConnectionLease()
|
||||||
@@ -83,9 +84,14 @@ class HermesWsClientSessionControlTest {
|
|||||||
runBlocking {
|
runBlocking {
|
||||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
|
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
|
||||||
}
|
}
|
||||||
runCatching { mockWebServer.shutdown() }
|
} finally {
|
||||||
|
try {
|
||||||
|
mockWebServer.shutdown()
|
||||||
|
} finally {
|
||||||
unmockkAll()
|
unmockkAll()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun connectClient() {
|
private fun connectClient() {
|
||||||
HermesWsClient.connect()
|
HermesWsClient.connect()
|
||||||
@@ -101,7 +107,7 @@ class HermesWsClientSessionControlTest {
|
|||||||
val frameRef = AtomicReference<JsonObject?>(null)
|
val frameRef = AtomicReference<JsonObject?>(null)
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -163,7 +169,7 @@ class HermesWsClientSessionControlTest {
|
|||||||
val frameRef = AtomicReference<JsonObject?>(null)
|
val frameRef = AtomicReference<JsonObject?>(null)
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -224,7 +230,7 @@ class HermesWsClientSessionControlTest {
|
|||||||
val frameRef = AtomicReference<JsonObject?>(null)
|
val frameRef = AtomicReference<JsonObject?>(null)
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
|
|||||||
@@ -104,19 +104,18 @@ class HermesWsClientTest {
|
|||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
|
try {
|
||||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||||
HermesWsClient.releaseBackgroundConnectionLease()
|
HermesWsClient.releaseBackgroundConnectionLease()
|
||||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||||
// Wait a bit to allow internal OkHttp coroutines to clean up before shutting down MockWebServer
|
} finally {
|
||||||
// Increased from 100ms for OkHttp 5.x — needs more time for the WS close handshake
|
|
||||||
Thread.sleep(500)
|
|
||||||
try {
|
try {
|
||||||
mockWebServer.shutdown()
|
mockWebServer.shutdown()
|
||||||
} catch (e: Throwable) {
|
} finally {
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
unmockkAll()
|
unmockkAll()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testConnectAndSend() {
|
fun testConnectAndSend() {
|
||||||
@@ -126,7 +125,7 @@ class HermesWsClientTest {
|
|||||||
var receivedMessage: String? = null
|
var receivedMessage: String? = null
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -171,7 +170,7 @@ class HermesWsClientTest {
|
|||||||
var capabilityFrame: String? = null
|
var capabilityFrame: String? = null
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -221,7 +220,7 @@ class HermesWsClientTest {
|
|||||||
val capabilityResponseCount = AtomicInteger(0)
|
val capabilityResponseCount = AtomicInteger(0)
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -301,7 +300,7 @@ class HermesWsClientTest {
|
|||||||
fun testOpenRequestsReplayUsesTheLiveServerRequestDispatcher() =
|
fun testOpenRequestsReplayUsesTheLiveServerRequestDispatcher() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -759,7 +758,7 @@ class HermesWsClientTest {
|
|||||||
HermesWsClient.connectedForTest.set(true)
|
HermesWsClient.connectedForTest.set(true)
|
||||||
val oldSocket = mockk<WebSocket>(relaxed = true)
|
val oldSocket = mockk<WebSocket>(relaxed = true)
|
||||||
HermesWsClient.webSocketForTest = oldSocket
|
HermesWsClient.webSocketForTest = oldSocket
|
||||||
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
|
mockWebServer.enqueue(MockResponse().withClosingWebSocketUpgrade(object : WebSocketListener() {}))
|
||||||
|
|
||||||
HermesWsClient.connect()
|
HermesWsClient.connect()
|
||||||
|
|
||||||
@@ -789,7 +788,7 @@ class HermesWsClientTest {
|
|||||||
assertTrue(refreshStarted.await(5, TimeUnit.SECONDS))
|
assertTrue(refreshStarted.await(5, TimeUnit.SECONDS))
|
||||||
|
|
||||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||||
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
|
mockWebServer.enqueue(MockResponse().withClosingWebSocketUpgrade(object : WebSocketListener() {}))
|
||||||
val disconnectedGeneration = HermesWsClient.connectionGenerationForTest.get()
|
val disconnectedGeneration = HermesWsClient.connectionGenerationForTest.get()
|
||||||
val replacementConnect = thread { HermesWsClient.connect() }
|
val replacementConnect = thread { HermesWsClient.connect() }
|
||||||
val generationDeadline = System.currentTimeMillis() + 5_000
|
val generationDeadline = System.currentTimeMillis() + 5_000
|
||||||
@@ -821,7 +820,7 @@ class HermesWsClientTest {
|
|||||||
val serverLatch = CountDownLatch(1)
|
val serverLatch = CountDownLatch(1)
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -862,7 +861,7 @@ class HermesWsClientTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testBackgroundWithoutPendingWorkDisconnects() {
|
fun testBackgroundWithoutPendingWorkDisconnects() {
|
||||||
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
|
mockWebServer.enqueue(MockResponse().withClosingWebSocketUpgrade(object : WebSocketListener() {}))
|
||||||
HermesWsClient.connect()
|
HermesWsClient.connect()
|
||||||
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
|
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
|
||||||
|
|
||||||
@@ -874,7 +873,7 @@ class HermesWsClientTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testExternalActivityLeaseKeepsIdleBackgroundSocketUntilReleased() {
|
fun testExternalActivityLeaseKeepsIdleBackgroundSocketUntilReleased() {
|
||||||
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
|
mockWebServer.enqueue(MockResponse().withClosingWebSocketUpgrade(object : WebSocketListener() {}))
|
||||||
HermesWsClient.connect()
|
HermesWsClient.connect()
|
||||||
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
|
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
|
||||||
|
|
||||||
@@ -891,7 +890,7 @@ class HermesWsClientTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testBackgroundConnectionLeaseKeepsIdleBackgroundSocketUntilReleased() {
|
fun testBackgroundConnectionLeaseKeepsIdleBackgroundSocketUntilReleased() {
|
||||||
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
|
mockWebServer.enqueue(MockResponse().withClosingWebSocketUpgrade(object : WebSocketListener() {}))
|
||||||
HermesWsClient.connect()
|
HermesWsClient.connect()
|
||||||
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
|
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
|
||||||
|
|
||||||
@@ -908,7 +907,7 @@ class HermesWsClientTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testBackgroundWithPendingReplyStaysConnected() {
|
fun testBackgroundWithPendingReplyStaysConnected() {
|
||||||
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
|
mockWebServer.enqueue(MockResponse().withClosingWebSocketUpgrade(object : WebSocketListener() {}))
|
||||||
HermesWsClient.connect()
|
HermesWsClient.connect()
|
||||||
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
|
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
|
||||||
HermesWsClient.sendMessage("session-1", "hello")
|
HermesWsClient.sendMessage("session-1", "hello")
|
||||||
@@ -923,7 +922,7 @@ class HermesWsClientTest {
|
|||||||
lateinit var serverSocket: WebSocket
|
lateinit var serverSocket: WebSocket
|
||||||
val connectedLatch = CountDownLatch(1)
|
val connectedLatch = CountDownLatch(1)
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -958,7 +957,7 @@ class HermesWsClientTest {
|
|||||||
fun testBackgroundQueuedSendDisconnectsAfterFlush() {
|
fun testBackgroundQueuedSendDisconnectsAfterFlush() {
|
||||||
val messageLatch = CountDownLatch(1)
|
val messageLatch = CountDownLatch(1)
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onMessage(
|
override fun onMessage(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -987,7 +986,7 @@ class HermesWsClientTest {
|
|||||||
lateinit var serverSocket: WebSocket
|
lateinit var serverSocket: WebSocket
|
||||||
val connectedLatch = CountDownLatch(1)
|
val connectedLatch = CountDownLatch(1)
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -1033,7 +1032,7 @@ class HermesWsClientTest {
|
|||||||
val closedLatch = CountDownLatch(1)
|
val closedLatch = CountDownLatch(1)
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -1073,7 +1072,7 @@ class HermesWsClientTest {
|
|||||||
var receivedMessage: String? = null
|
var receivedMessage: String? = null
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -1128,7 +1127,7 @@ class HermesWsClientTest {
|
|||||||
val connect2Latch = CountDownLatch(1)
|
val connect2Latch = CountDownLatch(1)
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -1142,7 +1141,7 @@ class HermesWsClientTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -1206,7 +1205,7 @@ class HermesWsClientTest {
|
|||||||
|
|
||||||
val serverLatch = CountDownLatch(1)
|
val serverLatch = CountDownLatch(1)
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
ws: WebSocket,
|
ws: WebSocket,
|
||||||
@@ -1235,7 +1234,7 @@ class HermesWsClientTest {
|
|||||||
|
|
||||||
val serverLatch = CountDownLatch(1)
|
val serverLatch = CountDownLatch(1)
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
ws: WebSocket,
|
ws: WebSocket,
|
||||||
@@ -1265,7 +1264,7 @@ class HermesWsClientTest {
|
|||||||
fun testDoubleConnect_ignoresSecondCallWhenConnected() {
|
fun testDoubleConnect_ignoresSecondCallWhenConnected() {
|
||||||
val serverLatch = CountDownLatch(1)
|
val serverLatch = CountDownLatch(1)
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
ws: WebSocket,
|
ws: WebSocket,
|
||||||
@@ -1291,7 +1290,7 @@ class HermesWsClientTest {
|
|||||||
fun testStatusTransitionOnConnect() {
|
fun testStatusTransitionOnConnect() {
|
||||||
val serverLatch = CountDownLatch(1)
|
val serverLatch = CountDownLatch(1)
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
ws: WebSocket,
|
ws: WebSocket,
|
||||||
@@ -1328,7 +1327,7 @@ class HermesWsClientTest {
|
|||||||
|
|
||||||
val connectLatch = CountDownLatch(1)
|
val connectLatch = CountDownLatch(1)
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
ws: WebSocket,
|
ws: WebSocket,
|
||||||
@@ -1342,7 +1341,7 @@ class HermesWsClientTest {
|
|||||||
|
|
||||||
// Enqueue a second response for reconnect attempt
|
// Enqueue a second response for reconnect attempt
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
ws: WebSocket,
|
ws: WebSocket,
|
||||||
@@ -1400,7 +1399,7 @@ class HermesWsClientTest {
|
|||||||
|
|
||||||
val connectLatch = CountDownLatch(1)
|
val connectLatch = CountDownLatch(1)
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
ws: WebSocket,
|
ws: WebSocket,
|
||||||
@@ -1537,7 +1536,7 @@ class HermesWsClientTest {
|
|||||||
var serverWebSocket: WebSocket? = null
|
var serverWebSocket: WebSocket? = null
|
||||||
val serverLatch = CountDownLatch(1)
|
val serverLatch = CountDownLatch(1)
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -1589,7 +1588,7 @@ class HermesWsClientTest {
|
|||||||
var serverWebSocket: WebSocket? = null
|
var serverWebSocket: WebSocket? = null
|
||||||
val serverLatch = CountDownLatch(1)
|
val serverLatch = CountDownLatch(1)
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -1664,7 +1663,7 @@ class HermesWsClientTest {
|
|||||||
var serverWebSocket: WebSocket? = null
|
var serverWebSocket: WebSocket? = null
|
||||||
val serverLatch = CountDownLatch(1)
|
val serverLatch = CountDownLatch(1)
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -1719,7 +1718,7 @@ class HermesWsClientTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -1780,7 +1779,7 @@ class HermesWsClientTest {
|
|||||||
val socket = mockk<WebSocket>(relaxed = true)
|
val socket = mockk<WebSocket>(relaxed = true)
|
||||||
HermesWsClient.intentionalCloseForTest.set(false)
|
HermesWsClient.intentionalCloseForTest.set(false)
|
||||||
val listener = HermesWsClient.createListenerForTest(HermesWsClient.connectionGenerationForTest.get())
|
val listener = HermesWsClient.createListenerForTest(HermesWsClient.connectionGenerationForTest.get())
|
||||||
// Directly install the reflection-built listener so onMessage runs even
|
// Directly install the transport listener so onMessage runs even
|
||||||
// though this test never performed a real OkHttp connect.
|
// though this test never performed a real OkHttp connect.
|
||||||
HermesWsClient.webSocketForTest = socket
|
HermesWsClient.webSocketForTest = socket
|
||||||
mockkObject(EventParser)
|
mockkObject(EventParser)
|
||||||
@@ -1826,7 +1825,7 @@ class HermesWsClientTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -1888,7 +1887,7 @@ class HermesWsClientTest {
|
|||||||
var pingReceived = false
|
var pingReceived = false
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -1971,7 +1970,7 @@ class HermesWsClientTest {
|
|||||||
val serverLatch = CountDownLatch(1)
|
val serverLatch = CountDownLatch(1)
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ class HermesWsClientTypedCallTest {
|
|||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
|
try {
|
||||||
AuthManager.resetAuthStateForTest()
|
AuthManager.resetAuthStateForTest()
|
||||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||||
HermesWsClient.releaseBackgroundConnectionLease()
|
HermesWsClient.releaseBackgroundConnectionLease()
|
||||||
@@ -110,9 +111,14 @@ class HermesWsClientTypedCallTest {
|
|||||||
runBlocking {
|
runBlocking {
|
||||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
|
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
|
||||||
}
|
}
|
||||||
runCatching { mockWebServer.shutdown() }
|
} finally {
|
||||||
|
try {
|
||||||
|
mockWebServer.shutdown()
|
||||||
|
} finally {
|
||||||
unmockkAll()
|
unmockkAll()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun connectClient() {
|
private fun connectClient() {
|
||||||
HermesWsClient.connect()
|
HermesWsClient.connect()
|
||||||
@@ -128,7 +134,7 @@ class HermesWsClientTypedCallTest {
|
|||||||
val frameRef = AtomicReference<JsonObject?>(null)
|
val frameRef = AtomicReference<JsonObject?>(null)
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -190,7 +196,7 @@ class HermesWsClientTypedCallTest {
|
|||||||
val opened = CountDownLatch(1)
|
val opened = CountDownLatch(1)
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -233,7 +239,7 @@ class HermesWsClientTypedCallTest {
|
|||||||
val opened = CountDownLatch(1)
|
val opened = CountDownLatch(1)
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -281,7 +287,7 @@ class HermesWsClientTypedCallTest {
|
|||||||
val frameRef = AtomicReference<JsonObject?>(null)
|
val frameRef = AtomicReference<JsonObject?>(null)
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -332,7 +338,7 @@ class HermesWsClientTypedCallTest {
|
|||||||
val frameRef = AtomicReference<JsonObject?>(null)
|
val frameRef = AtomicReference<JsonObject?>(null)
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -382,7 +388,7 @@ class HermesWsClientTypedCallTest {
|
|||||||
val frameRef = AtomicReference<JsonObject?>(null)
|
val frameRef = AtomicReference<JsonObject?>(null)
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -429,7 +435,7 @@ class HermesWsClientTypedCallTest {
|
|||||||
val opened = CountDownLatch(1)
|
val opened = CountDownLatch(1)
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -475,7 +481,7 @@ class HermesWsClientTypedCallTest {
|
|||||||
val requestReceived = CountDownLatch(1)
|
val requestReceived = CountDownLatch(1)
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ class HermesWsClientTypedSendTest {
|
|||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
|
try {
|
||||||
AuthManager.resetAuthStateForTest()
|
AuthManager.resetAuthStateForTest()
|
||||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||||
HermesWsClient.releaseBackgroundConnectionLease()
|
HermesWsClient.releaseBackgroundConnectionLease()
|
||||||
@@ -84,9 +85,14 @@ class HermesWsClientTypedSendTest {
|
|||||||
runBlocking {
|
runBlocking {
|
||||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
|
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
|
||||||
}
|
}
|
||||||
runCatching { mockWebServer.shutdown() }
|
} finally {
|
||||||
|
try {
|
||||||
|
mockWebServer.shutdown()
|
||||||
|
} finally {
|
||||||
unmockkAll()
|
unmockkAll()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun connectClient() {
|
private fun connectClient() {
|
||||||
HermesWsClient.connect()
|
HermesWsClient.connect()
|
||||||
@@ -102,7 +108,7 @@ class HermesWsClientTypedSendTest {
|
|||||||
val frameRef = AtomicReference<JsonObject?>(null)
|
val frameRef = AtomicReference<JsonObject?>(null)
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -163,7 +169,7 @@ class HermesWsClientTypedSendTest {
|
|||||||
val frameRef = AtomicReference<JsonObject?>(null)
|
val frameRef = AtomicReference<JsonObject?>(null)
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -216,7 +222,7 @@ class HermesWsClientTypedSendTest {
|
|||||||
val frameRef = AtomicReference<JsonObject?>(null)
|
val frameRef = AtomicReference<JsonObject?>(null)
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -274,7 +280,7 @@ class HermesWsClientTypedSendTest {
|
|||||||
val frameRef = AtomicReference<JsonObject?>(null)
|
val frameRef = AtomicReference<JsonObject?>(null)
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -325,7 +331,7 @@ class HermesWsClientTypedSendTest {
|
|||||||
val frameRef = AtomicReference<JsonObject?>(null)
|
val frameRef = AtomicReference<JsonObject?>(null)
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -382,7 +388,7 @@ class HermesWsClientTypedSendTest {
|
|||||||
val frameRef = AtomicReference<JsonObject?>(null)
|
val frameRef = AtomicReference<JsonObject?>(null)
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
@@ -437,7 +443,7 @@ class HermesWsClientTypedSendTest {
|
|||||||
val frameRef = AtomicReference<JsonObject?>(null)
|
val frameRef = AtomicReference<JsonObject?>(null)
|
||||||
|
|
||||||
mockWebServer.enqueue(
|
mockWebServer.enqueue(
|
||||||
MockResponse().withWebSocketUpgrade(
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
object : WebSocketListener() {
|
object : WebSocketListener() {
|
||||||
override fun onOpen(
|
override fun onOpen(
|
||||||
webSocket: WebSocket,
|
webSocket: WebSocket,
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okio.ByteString
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the test listener's behavior, but acknowledge peer close frames so MockWebServer's
|
||||||
|
* WebSocket task can finish. WebSocketListener's default onClosing is a no-op.
|
||||||
|
*/
|
||||||
|
internal fun MockResponse.withClosingWebSocketUpgrade(delegate: WebSocketListener): MockResponse =
|
||||||
|
withWebSocketUpgrade(
|
||||||
|
object : WebSocketListener() {
|
||||||
|
override fun onOpen(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
response: Response,
|
||||||
|
) = delegate.onOpen(webSocket, response)
|
||||||
|
|
||||||
|
override fun onMessage(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
text: String,
|
||||||
|
) = delegate.onMessage(webSocket, text)
|
||||||
|
|
||||||
|
override fun onMessage(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
bytes: ByteString,
|
||||||
|
) = delegate.onMessage(webSocket, bytes)
|
||||||
|
|
||||||
|
override fun onClosing(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
code: Int,
|
||||||
|
reason: String,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
delegate.onClosing(webSocket, code, reason)
|
||||||
|
} finally {
|
||||||
|
// The delegate may already have initiated a close; a false return is valid.
|
||||||
|
webSocket.close(code, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
code: Int,
|
||||||
|
reason: String,
|
||||||
|
) = delegate.onClosed(webSocket, code, reason)
|
||||||
|
|
||||||
|
override fun onFailure(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
t: Throwable,
|
||||||
|
response: Response?,
|
||||||
|
) = delegate.onFailure(webSocket, t, response)
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.verifySequence
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okio.ByteString.Companion.encodeUtf8
|
||||||
|
import org.junit.Assert.assertSame
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class MockWebSocketCleanupListenerTest {
|
||||||
|
private val socket = mockk<WebSocket>()
|
||||||
|
private val delegate = mockk<WebSocketListener>(relaxed = true)
|
||||||
|
private val listener: WebSocketListener
|
||||||
|
get() = requireNotNull(MockResponse().withClosingWebSocketUpgrade(delegate).webSocketListener)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun acknowledgesCloseAfterDelegatedCallback() {
|
||||||
|
every { socket.close(1000, "complete") } returns true
|
||||||
|
|
||||||
|
listener.onClosing(socket, 1000, "complete")
|
||||||
|
|
||||||
|
verifySequence {
|
||||||
|
delegate.onClosing(socket, 1000, "complete")
|
||||||
|
socket.close(1000, "complete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun acknowledgesCloseEvenWhenDelegatedCallbackThrows() {
|
||||||
|
val failure = IllegalStateException("callback failure")
|
||||||
|
every { delegate.onClosing(socket, 1000, "complete") } throws failure
|
||||||
|
every { socket.close(1000, "complete") } returns true
|
||||||
|
|
||||||
|
val thrown =
|
||||||
|
assertThrows(IllegalStateException::class.java) {
|
||||||
|
listener.onClosing(socket, 1000, "complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertSame(failure, thrown)
|
||||||
|
verifySequence {
|
||||||
|
delegate.onClosing(socket, 1000, "complete")
|
||||||
|
socket.close(1000, "complete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun acceptsAlreadyInitiatedCustomCloseWithoutOverridingIt() {
|
||||||
|
every { socket.close(1001, "custom close") } returns true
|
||||||
|
every { socket.close(1000, "complete") } returns false
|
||||||
|
every { delegate.onClosing(socket, 1000, "complete") } answers {
|
||||||
|
socket.close(1001, "custom close")
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
listener.onClosing(socket, 1000, "complete")
|
||||||
|
|
||||||
|
verifySequence {
|
||||||
|
delegate.onClosing(socket, 1000, "complete")
|
||||||
|
socket.close(1001, "custom close")
|
||||||
|
socket.close(1000, "complete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun forwardsOpenWithOriginalSocketAndResponse() {
|
||||||
|
val response = mockk<Response>()
|
||||||
|
|
||||||
|
listener.onOpen(socket, response)
|
||||||
|
|
||||||
|
verifySequence { delegate.onOpen(socket, response) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun forwardsBothTextAndBinaryMessages() {
|
||||||
|
val bytes = "binary message".encodeUtf8()
|
||||||
|
val wrapped = listener
|
||||||
|
|
||||||
|
wrapped.onMessage(socket, "text message")
|
||||||
|
wrapped.onMessage(socket, bytes)
|
||||||
|
|
||||||
|
verifySequence {
|
||||||
|
delegate.onMessage(socket, "text message")
|
||||||
|
delegate.onMessage(socket, bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun forwardsClosedWithOriginalCodeAndReason() {
|
||||||
|
listener.onClosed(socket, 1001, "server close")
|
||||||
|
|
||||||
|
verifySequence { delegate.onClosed(socket, 1001, "server close") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun forwardsFailureWithOriginalThrowableAndNullableResponse() {
|
||||||
|
val failure = IllegalStateException("transport failure")
|
||||||
|
val response = mockk<Response>()
|
||||||
|
val wrapped = listener
|
||||||
|
|
||||||
|
wrapped.onFailure(socket, failure, response)
|
||||||
|
wrapped.onFailure(socket, failure, null)
|
||||||
|
|
||||||
|
verifySequence {
|
||||||
|
delegate.onFailure(socket, failure, response)
|
||||||
|
delegate.onFailure(socket, failure, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.unmockkAll
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
|
class MockWebSocketCleanupTest {
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
mockkStatic(Log::class)
|
||||||
|
every { Log.isLoggable(any(), any()) } returns false
|
||||||
|
every { Log.println(any(), any(), any()) } returns 0
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
unmockkAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun peerCloseAllowsMockServerShutdownWithoutLeavingRunningTasks() {
|
||||||
|
val server = MockWebServer()
|
||||||
|
val client = OkHttpClient()
|
||||||
|
val serverSocket = AtomicReference<WebSocket>()
|
||||||
|
val opened = CountDownLatch(1)
|
||||||
|
val closing = CountDownLatch(1)
|
||||||
|
val closed = CountDownLatch(1)
|
||||||
|
server.enqueue(
|
||||||
|
MockResponse().withClosingWebSocketUpgrade(
|
||||||
|
object : WebSocketListener() {
|
||||||
|
override fun onOpen(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
response: Response,
|
||||||
|
) {
|
||||||
|
serverSocket.set(webSocket)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosing(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
code: Int,
|
||||||
|
reason: String,
|
||||||
|
) {
|
||||||
|
closing.countDown()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
server.start()
|
||||||
|
val socket =
|
||||||
|
client.newWebSocket(
|
||||||
|
Request.Builder().url(server.url("/")).build(),
|
||||||
|
object : WebSocketListener() {
|
||||||
|
override fun onOpen(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
response: Response,
|
||||||
|
) {
|
||||||
|
opened.countDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
code: Int,
|
||||||
|
reason: String,
|
||||||
|
) {
|
||||||
|
closed.countDown()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
assertTrue("The WebSocket must open", opened.await(5, TimeUnit.SECONDS))
|
||||||
|
assertTrue(socket.close(1000, "test complete"))
|
||||||
|
assertTrue("The mock peer must observe the close", closing.await(5, TimeUnit.SECONDS))
|
||||||
|
// Complete the handshake before shutdown stops the server's task runner.
|
||||||
|
assertTrue("The close handshake must complete", closed.await(5, TimeUnit.SECONDS))
|
||||||
|
// No fixed sleep or swallowed shutdown exception: an active server queue fails this regression.
|
||||||
|
server.shutdown()
|
||||||
|
} finally {
|
||||||
|
socket.cancel()
|
||||||
|
// MockWebServer's accepted socket has no outbound Call, so cancel() is not supported.
|
||||||
|
serverSocket.get()?.close(1000, "cleanup")
|
||||||
|
try {
|
||||||
|
server.shutdown()
|
||||||
|
} finally {
|
||||||
|
client.dispatcher.executorService.shutdown()
|
||||||
|
client.connectionPool.evictAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,6 +118,48 @@ class SessionPromptTypesTest {
|
|||||||
assertEquals(setOf("session_id", "omit_messages", "profile"), (encoded as JsonObject).keys)
|
assertEquals(setOf("session_id", "omit_messages", "profile"), (encoded as JsonObject).keys)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resumeWithDesktopSourceEncodesSourceKey() {
|
||||||
|
val params =
|
||||||
|
SessionResumeParams(
|
||||||
|
sessionId = "s-457",
|
||||||
|
source = DESKTOP_SESSION_SOURCE,
|
||||||
|
omitMessages = true,
|
||||||
|
)
|
||||||
|
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionResumeParams.serializer(), params)
|
||||||
|
|
||||||
|
val expected =
|
||||||
|
buildJsonObject {
|
||||||
|
put("session_id", "s-457")
|
||||||
|
put("source", "desktop")
|
||||||
|
put("omit_messages", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(expected, encoded)
|
||||||
|
assertTrue(encoded is JsonObject)
|
||||||
|
assertEquals(setOf("session_id", "source", "omit_messages"), (encoded as JsonObject).keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resumeOmitsSourceWhenUnset() {
|
||||||
|
val encoded =
|
||||||
|
OkHttpProvider.json.encodeToJsonElement(
|
||||||
|
SessionResumeParams.serializer(),
|
||||||
|
SessionResumeParams(sessionId = "s-458"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(encoded is JsonObject)
|
||||||
|
assertNull((encoded as JsonObject)["source"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createAndResumeDeclareTheSameDesktopSource() {
|
||||||
|
// #1450: create passed source and resume did not, so the gateway resolved the resumed
|
||||||
|
// runtime from its host env ("tui") and staged a bogus surface switch. Same constant,
|
||||||
|
// so the two call sites cannot drift again.
|
||||||
|
assertEquals("desktop", DESKTOP_SESSION_SOURCE)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun resumeWithExplicitFalseOmitMessagesIsEncoded() {
|
fun resumeWithExplicitFalseOmitMessagesIsEncoded() {
|
||||||
val params = SessionResumeParams(sessionId = "s-789", omitMessages = false)
|
val params = SessionResumeParams(sessionId = "s-789", omitMessages = false)
|
||||||
|
|||||||
+7
-1
@@ -330,7 +330,13 @@ class NotificationReplyReceiverTest {
|
|||||||
verify {
|
verify {
|
||||||
HermesWsClient.request(
|
HermesWsClient.request(
|
||||||
WsMethods.SESSION_RESUME,
|
WsMethods.SESSION_RESUME,
|
||||||
match { it.str("session_id") == "session-abc" && it["omit_messages"] == JsonPrimitive(true) },
|
match {
|
||||||
|
it.str("session_id") == "session-abc" &&
|
||||||
|
it["omit_messages"] == JsonPrimitive(true) &&
|
||||||
|
// #1450: source must match session.create, or the gateway resolves the
|
||||||
|
// resumed runtime from its host env and stages a bogus surface switch.
|
||||||
|
it["source"] == JsonPrimitive("desktop")
|
||||||
|
},
|
||||||
any(),
|
any(),
|
||||||
)
|
)
|
||||||
HermesWsClient.request(
|
HermesWsClient.request(
|
||||||
|
|||||||
@@ -0,0 +1,464 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression coverage for issue #1451:
|
||||||
|
* Transcript ordering when REST confirms a newer prompt while older live rows
|
||||||
|
* (observed live user/tool + review notices) remain unmatched/unconfirmed.
|
||||||
|
*/
|
||||||
|
class ChatLiveOrderingRegressionTest {
|
||||||
|
@Test
|
||||||
|
fun unconfirmedLiveUserAndToolWithReviewNoticesStayBeforeConfirmedNewerPrompt() {
|
||||||
|
// Given an observed live history:
|
||||||
|
// 1. Live user prompt (unconfirmed by REST)
|
||||||
|
// 2. Live tool call / result (unconfirmed by REST)
|
||||||
|
// 3. First system review notice
|
||||||
|
// 4. Second system review notice
|
||||||
|
// 5. Later live user prompt
|
||||||
|
val liveUserOld =
|
||||||
|
ChatMessage(
|
||||||
|
id = "live-user-old",
|
||||||
|
role = MessageRole.USER,
|
||||||
|
content = "first task",
|
||||||
|
timestamp = 1_000L,
|
||||||
|
)
|
||||||
|
val liveTool =
|
||||||
|
ChatMessage(
|
||||||
|
id = "live-tool-1",
|
||||||
|
role = MessageRole.TOOL,
|
||||||
|
content = """{"result":{"output":"task done","exit_code":0}}""",
|
||||||
|
toolName = "terminal",
|
||||||
|
toolCallId = "call_task_1",
|
||||||
|
timestamp = 1_100L,
|
||||||
|
)
|
||||||
|
val systemReview1 =
|
||||||
|
ChatMessage(
|
||||||
|
id = "sys-review-1",
|
||||||
|
role = MessageRole.SYSTEM,
|
||||||
|
content = "💾 Self-improvement review: skill updated",
|
||||||
|
timestamp = 1_200L,
|
||||||
|
)
|
||||||
|
val systemReview2 =
|
||||||
|
ChatMessage(
|
||||||
|
id = "sys-review-2",
|
||||||
|
role = MessageRole.SYSTEM,
|
||||||
|
content = "💾 Self-improvement review: playbook refreshed",
|
||||||
|
timestamp = 1_300L,
|
||||||
|
)
|
||||||
|
val liveUserNew =
|
||||||
|
ChatMessage(
|
||||||
|
id = "live-user-new",
|
||||||
|
role = MessageRole.USER,
|
||||||
|
content = "second prompt",
|
||||||
|
timestamp = 1_400L,
|
||||||
|
)
|
||||||
|
|
||||||
|
val currentMessages =
|
||||||
|
listOf(
|
||||||
|
liveUserOld,
|
||||||
|
liveTool,
|
||||||
|
systemReview1,
|
||||||
|
systemReview2,
|
||||||
|
liveUserNew,
|
||||||
|
)
|
||||||
|
|
||||||
|
// REST sync only returns/confirms the second prompt (e.g. rest-s-5),
|
||||||
|
// while the older live user/tool and review notices remain unmatched in REST.
|
||||||
|
val restConfirmedNew =
|
||||||
|
ChatMessage(
|
||||||
|
id = "rest-s-5",
|
||||||
|
role = MessageRole.USER,
|
||||||
|
content = "second prompt",
|
||||||
|
timestamp = 1_450L,
|
||||||
|
)
|
||||||
|
val restMessages = listOf(restConfirmedNew)
|
||||||
|
|
||||||
|
// When merged with chronological = true, preserveLiveIds = false
|
||||||
|
val merged =
|
||||||
|
mergeTranscriptWithLive(
|
||||||
|
restMessages = restMessages,
|
||||||
|
currentMessages = currentMessages,
|
||||||
|
chronological = true,
|
||||||
|
preserveLiveIds = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Then:
|
||||||
|
// 1. All rows must be preserved.
|
||||||
|
assertEquals(5, merged.size)
|
||||||
|
|
||||||
|
// 2. Ordering contract: the older live user, tool, and two system review notices
|
||||||
|
// MUST remain before the newer confirmed prompt.
|
||||||
|
val expectedIds =
|
||||||
|
listOf(
|
||||||
|
"live-user-old",
|
||||||
|
"live-tool-1",
|
||||||
|
"sys-review-1",
|
||||||
|
"sys-review-2",
|
||||||
|
"live-user-new", // Merged with rest-s-5; when role=USER, match's ID is preserved, restId recorded
|
||||||
|
)
|
||||||
|
assertEquals(expectedIds, merged.map { it.id })
|
||||||
|
|
||||||
|
// 3. The confirmed prompt acquires its canonical REST identity
|
||||||
|
val confirmedRow = merged.last()
|
||||||
|
assertEquals("rest-s-5", confirmedRow.canonicalRestId)
|
||||||
|
assertEquals(MessageRole.USER, confirmedRow.role)
|
||||||
|
assertEquals("second prompt", confirmedRow.content)
|
||||||
|
|
||||||
|
// 4. Older unconfirmed rows must NOT invent or acquire rest identities
|
||||||
|
val unconfirmedRows = merged.take(4)
|
||||||
|
unconfirmedRows.forEach { row ->
|
||||||
|
assertNotEquals("rest-s-5", row.restId)
|
||||||
|
assertEquals(null, row.canonicalRestId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Repeated merge must be idempotent
|
||||||
|
val repeatedMerge =
|
||||||
|
mergeTranscriptWithLive(
|
||||||
|
restMessages = restMessages,
|
||||||
|
currentMessages = merged,
|
||||||
|
chronological = true,
|
||||||
|
preserveLiveIds = false,
|
||||||
|
)
|
||||||
|
assertEquals(merged.map { it.id }, repeatedMerge.map { it.id })
|
||||||
|
assertEquals(merged, repeatedMerge)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun controlPlainAppendAndNonConfirmationMaintainsObservedLiveOrder() {
|
||||||
|
// Control scenario:
|
||||||
|
// Current live transcript contains an old user prompt, tool call, two review notices,
|
||||||
|
// and a later user prompt.
|
||||||
|
// REST returns no confirmations (emptyList) or an unmatching server message.
|
||||||
|
val liveUserOld =
|
||||||
|
ChatMessage(
|
||||||
|
id = "live-user-old",
|
||||||
|
role = MessageRole.USER,
|
||||||
|
content = "first task",
|
||||||
|
timestamp = 1_000L,
|
||||||
|
)
|
||||||
|
val liveTool =
|
||||||
|
ChatMessage(
|
||||||
|
id = "live-tool-1",
|
||||||
|
role = MessageRole.TOOL,
|
||||||
|
content = """{"result":{"output":"task done","exit_code":0}}""",
|
||||||
|
toolName = "terminal",
|
||||||
|
toolCallId = "call_task_1",
|
||||||
|
timestamp = 1_100L,
|
||||||
|
)
|
||||||
|
val systemReview1 =
|
||||||
|
ChatMessage(
|
||||||
|
id = "sys-review-1",
|
||||||
|
role = MessageRole.SYSTEM,
|
||||||
|
content = "💾 Self-improvement review: note 1",
|
||||||
|
timestamp = 1_200L,
|
||||||
|
)
|
||||||
|
val systemReview2 =
|
||||||
|
ChatMessage(
|
||||||
|
id = "sys-review-2",
|
||||||
|
role = MessageRole.SYSTEM,
|
||||||
|
content = "💾 Self-improvement review: note 2",
|
||||||
|
timestamp = 1_300L,
|
||||||
|
)
|
||||||
|
val liveUserNew =
|
||||||
|
ChatMessage(
|
||||||
|
id = "live-user-new",
|
||||||
|
role = MessageRole.USER,
|
||||||
|
content = "second prompt",
|
||||||
|
timestamp = 1_400L,
|
||||||
|
)
|
||||||
|
|
||||||
|
val currentMessages =
|
||||||
|
listOf(
|
||||||
|
liveUserOld,
|
||||||
|
liveTool,
|
||||||
|
systemReview1,
|
||||||
|
systemReview2,
|
||||||
|
liveUserNew,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Case A: REST sync is empty
|
||||||
|
val mergedEmpty =
|
||||||
|
mergeTranscriptWithLive(
|
||||||
|
restMessages = emptyList(),
|
||||||
|
currentMessages = currentMessages,
|
||||||
|
chronological = true,
|
||||||
|
preserveLiveIds = false,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
"live-user-old",
|
||||||
|
"live-tool-1",
|
||||||
|
"sys-review-1",
|
||||||
|
"sys-review-2",
|
||||||
|
"live-user-new",
|
||||||
|
),
|
||||||
|
mergedEmpty.map {
|
||||||
|
it.id
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Case B: REST returns an unrelated / earlier server message (e.g. session start rest-s-0)
|
||||||
|
val canonicalPrior =
|
||||||
|
ChatMessage(
|
||||||
|
id = "rest-s-0",
|
||||||
|
role = MessageRole.USER,
|
||||||
|
content = "very first conversation starter",
|
||||||
|
timestamp = 500L,
|
||||||
|
)
|
||||||
|
val mergedWithPrior =
|
||||||
|
mergeTranscriptWithLive(
|
||||||
|
restMessages = listOf(canonicalPrior),
|
||||||
|
currentMessages = currentMessages,
|
||||||
|
chronological = true,
|
||||||
|
preserveLiveIds = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Canonical server rows maintain server order, while observed live unconfirmed block stays intact
|
||||||
|
assertEquals(
|
||||||
|
listOf("rest-s-0", "live-user-old", "live-tool-1", "sys-review-1", "sys-review-2", "live-user-new"),
|
||||||
|
mergedWithPrior.map { it.id },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Idempotence check on control
|
||||||
|
val idempotentMergedWithPrior =
|
||||||
|
mergeTranscriptWithLive(
|
||||||
|
restMessages = listOf(canonicalPrior),
|
||||||
|
currentMessages = mergedWithPrior,
|
||||||
|
chronological = true,
|
||||||
|
preserveLiveIds = false,
|
||||||
|
)
|
||||||
|
assertEquals(mergedWithPrior.map { it.id }, idempotentMergedWithPrior.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sessionStartMarkerRemainsBeforeOldUnresolvedUserToolReviewBlockWhenNewerUserConfirmsAsRestS0() {
|
||||||
|
val sessionStart =
|
||||||
|
ChatMessage(
|
||||||
|
id = "system-session-start",
|
||||||
|
role = MessageRole.SYSTEM,
|
||||||
|
content = "Session created",
|
||||||
|
timestamp = 100L,
|
||||||
|
)
|
||||||
|
val liveUserOld =
|
||||||
|
ChatMessage(
|
||||||
|
id = "live-user-old",
|
||||||
|
role = MessageRole.USER,
|
||||||
|
content = "unconfirmed first prompt",
|
||||||
|
timestamp = 200L,
|
||||||
|
)
|
||||||
|
val liveTool =
|
||||||
|
ChatMessage(
|
||||||
|
id = "live-tool-1",
|
||||||
|
role = MessageRole.TOOL,
|
||||||
|
content = """{"result":"done"}""",
|
||||||
|
toolName = "terminal",
|
||||||
|
toolCallId = "call_1",
|
||||||
|
timestamp = 300L,
|
||||||
|
)
|
||||||
|
val sysReview =
|
||||||
|
ChatMessage(
|
||||||
|
id = "sys-review-1",
|
||||||
|
role = MessageRole.SYSTEM,
|
||||||
|
content = "💾 Self-improvement review: note",
|
||||||
|
timestamp = 400L,
|
||||||
|
)
|
||||||
|
val liveUserNew =
|
||||||
|
ChatMessage(
|
||||||
|
id = "live-user-new",
|
||||||
|
role = MessageRole.USER,
|
||||||
|
content = "confirmed second prompt",
|
||||||
|
timestamp = 500L,
|
||||||
|
)
|
||||||
|
|
||||||
|
val currentMessages =
|
||||||
|
listOf(
|
||||||
|
sessionStart,
|
||||||
|
liveUserOld,
|
||||||
|
liveTool,
|
||||||
|
sysReview,
|
||||||
|
liveUserNew,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Newer observed USER confirms as canonical index 0 (rest-s-0)
|
||||||
|
val restConfirmedNew =
|
||||||
|
ChatMessage(
|
||||||
|
id = "rest-s-0",
|
||||||
|
role = MessageRole.USER,
|
||||||
|
content = "confirmed second prompt",
|
||||||
|
timestamp = 550L,
|
||||||
|
)
|
||||||
|
|
||||||
|
val merged =
|
||||||
|
mergeTranscriptWithLive(
|
||||||
|
restMessages = listOf(restConfirmedNew),
|
||||||
|
currentMessages = currentMessages,
|
||||||
|
chronological = true,
|
||||||
|
preserveLiveIds = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
val expectedIds =
|
||||||
|
listOf(
|
||||||
|
"system-session-start",
|
||||||
|
"live-user-old",
|
||||||
|
"live-tool-1",
|
||||||
|
"sys-review-1",
|
||||||
|
"live-user-new",
|
||||||
|
)
|
||||||
|
assertEquals(expectedIds, merged.map { it.id })
|
||||||
|
assertEquals("rest-s-0", merged.last().canonicalRestId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun canonicalRowsRemainServerOrderedAcrossGapsWithOldLiveBlockBeforeNextConfirmedUserAcrossSkewedTimestamps() {
|
||||||
|
// Skewed/inverted timestamps across rows
|
||||||
|
val canonicalPrior =
|
||||||
|
ChatMessage(
|
||||||
|
id = "rest-s-2",
|
||||||
|
role = MessageRole.USER,
|
||||||
|
content = "prior prompt",
|
||||||
|
timestamp = 8_000L, // skewed higher timestamp than later turns
|
||||||
|
)
|
||||||
|
val liveUserOld =
|
||||||
|
ChatMessage(
|
||||||
|
id = "live-user-old",
|
||||||
|
role = MessageRole.USER,
|
||||||
|
content = "unresolved old prompt",
|
||||||
|
timestamp = 2_000L,
|
||||||
|
)
|
||||||
|
val liveTool =
|
||||||
|
ChatMessage(
|
||||||
|
id = "live-tool-1",
|
||||||
|
role = MessageRole.TOOL,
|
||||||
|
content = """{"result":"ok"}""",
|
||||||
|
toolName = "terminal",
|
||||||
|
toolCallId = "call_skew",
|
||||||
|
timestamp = 2_000L, // equal timestamp
|
||||||
|
)
|
||||||
|
val sysReview =
|
||||||
|
ChatMessage(
|
||||||
|
id = "sys-review-1",
|
||||||
|
role = MessageRole.SYSTEM,
|
||||||
|
content = "💾 Self-improvement review: updated",
|
||||||
|
timestamp = 1_000L, // earlier timestamp
|
||||||
|
)
|
||||||
|
val liveUserNew =
|
||||||
|
ChatMessage(
|
||||||
|
id = "live-user-new",
|
||||||
|
role = MessageRole.USER,
|
||||||
|
content = "next user prompt",
|
||||||
|
timestamp = 3_000L,
|
||||||
|
)
|
||||||
|
|
||||||
|
val currentMessages =
|
||||||
|
listOf(
|
||||||
|
canonicalPrior,
|
||||||
|
liveUserOld,
|
||||||
|
liveTool,
|
||||||
|
sysReview,
|
||||||
|
liveUserNew,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Gap in canonical sequence: rest-s-2 then rest-s-9 (gap 3..8 missing/unloaded)
|
||||||
|
val canonicalFollow =
|
||||||
|
ChatMessage(
|
||||||
|
id = "rest-s-9",
|
||||||
|
role = MessageRole.USER,
|
||||||
|
content = "next user prompt",
|
||||||
|
timestamp = 2_500L, // skewed timestamp compared to rest-s-2
|
||||||
|
)
|
||||||
|
val restMessages = listOf(canonicalPrior, canonicalFollow)
|
||||||
|
|
||||||
|
val merged =
|
||||||
|
mergeTranscriptWithLive(
|
||||||
|
restMessages = restMessages,
|
||||||
|
currentMessages = currentMessages,
|
||||||
|
chronological = true,
|
||||||
|
preserveLiveIds = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
val expectedIds =
|
||||||
|
listOf(
|
||||||
|
"rest-s-2",
|
||||||
|
"live-user-old",
|
||||||
|
"live-tool-1",
|
||||||
|
"sys-review-1",
|
||||||
|
"live-user-new",
|
||||||
|
)
|
||||||
|
assertEquals(expectedIds, merged.map { it.id })
|
||||||
|
|
||||||
|
// Repeated merge must remain identical
|
||||||
|
val repeatedMerge =
|
||||||
|
mergeTranscriptWithLive(
|
||||||
|
restMessages = restMessages,
|
||||||
|
currentMessages = merged,
|
||||||
|
chronological = true,
|
||||||
|
preserveLiveIds = false,
|
||||||
|
)
|
||||||
|
assertEquals(merged.map { it.id }, repeatedMerge.map { it.id })
|
||||||
|
assertEquals(merged, repeatedMerge)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun assistantConfirmationDoesNotPullUnresolvedUserOutOfPendingTail() {
|
||||||
|
val unresolvedUser =
|
||||||
|
ChatMessage(
|
||||||
|
id = "live-user-unresolved",
|
||||||
|
role = MessageRole.USER,
|
||||||
|
content = "prompt not matching rest",
|
||||||
|
timestamp = 1_000L,
|
||||||
|
)
|
||||||
|
val liveTool =
|
||||||
|
ChatMessage(
|
||||||
|
id = "live-tool-1",
|
||||||
|
role = MessageRole.TOOL,
|
||||||
|
content = """{"result":"in progress"}""",
|
||||||
|
toolName = "terminal",
|
||||||
|
toolCallId = "call_assistant_test",
|
||||||
|
timestamp = 1_100L,
|
||||||
|
)
|
||||||
|
val liveAssistant =
|
||||||
|
ChatMessage(
|
||||||
|
id = "live-assistant-1",
|
||||||
|
role = MessageRole.ASSISTANT,
|
||||||
|
content = "Here is the response",
|
||||||
|
timestamp = 1_200L,
|
||||||
|
)
|
||||||
|
|
||||||
|
val currentMessages =
|
||||||
|
listOf(
|
||||||
|
unresolvedUser,
|
||||||
|
liveTool,
|
||||||
|
liveAssistant,
|
||||||
|
)
|
||||||
|
|
||||||
|
// REST confirms only the ASSISTANT message as rest-s-4 (e.g. user was dropped or mismatched)
|
||||||
|
val restConfirmedAssistant =
|
||||||
|
ChatMessage(
|
||||||
|
id = "rest-s-4",
|
||||||
|
role = MessageRole.ASSISTANT,
|
||||||
|
content = "Here is the response",
|
||||||
|
timestamp = 1_200L,
|
||||||
|
)
|
||||||
|
|
||||||
|
val merged =
|
||||||
|
mergeTranscriptWithLive(
|
||||||
|
restMessages = listOf(restConfirmedAssistant),
|
||||||
|
currentMessages = currentMessages,
|
||||||
|
chronological = true,
|
||||||
|
preserveLiveIds = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assistant confirmation alone must not anchor/pull the unresolved user or tool
|
||||||
|
// before rest-s-4. The confirmed assistant appears first, unresolved user/tool remain in pending tail.
|
||||||
|
val expectedIds =
|
||||||
|
listOf(
|
||||||
|
"rest-s-4",
|
||||||
|
"live-user-unresolved",
|
||||||
|
"live-tool-1",
|
||||||
|
)
|
||||||
|
assertEquals(expectedIds, merged.map { it.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import org.junit.Assert.assertArrayEquals
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assert.fail
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.TemporaryFolder
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.Callable
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class ChatPastedImageStoreTest {
|
||||||
|
@get:Rule val temp = TemporaryFolder()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun startupSweepDeletesOnlyPastedImagesOlderThanSevenDays() {
|
||||||
|
val cacheDir = temp.newFolder("cache")
|
||||||
|
val directory = cacheDir.resolve("pasted_images").apply { mkdir() }
|
||||||
|
val now = 2_000_000_000_000L
|
||||||
|
val cutoff = now - TimeUnit.DAYS.toMillis(7)
|
||||||
|
val stale = stagePastedImage(ByteArrayInputStream(byteArrayOf(1)), directory, "png")!!
|
||||||
|
assertTrue(stale.setLastModified(cutoff - 1000))
|
||||||
|
val boundary = directory.resolve("boundary.png").apply { writeText("keep") }
|
||||||
|
assertTrue(boundary.setLastModified(cutoff))
|
||||||
|
val recent = directory.resolve("recent.png").apply { writeText("keep") }
|
||||||
|
assertTrue(recent.setLastModified(now))
|
||||||
|
val future = directory.resolve("future.png").apply { writeText("keep") }
|
||||||
|
assertTrue(future.setLastModified(now + 1000))
|
||||||
|
val unrelated = cacheDir.resolve("unrelated.png").apply { writeText("keep") }
|
||||||
|
assertTrue(unrelated.setLastModified(cutoff - 1000))
|
||||||
|
val nested =
|
||||||
|
directory
|
||||||
|
.resolve("nested")
|
||||||
|
.apply { mkdir() }
|
||||||
|
.resolve("old.png")
|
||||||
|
.apply { writeText("keep") }
|
||||||
|
assertTrue(nested.setLastModified(cutoff - 1000))
|
||||||
|
|
||||||
|
cleanStalePastedImages(cacheDir, now)
|
||||||
|
cleanStalePastedImages(cacheDir, now)
|
||||||
|
|
||||||
|
assertFalse(stale.exists())
|
||||||
|
listOf(boundary, recent, future, unrelated, nested).forEach { assertEquals("keep", it.readText()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun startupSweepDoesNotCreateAMissingCacheDirectory() {
|
||||||
|
val cacheDir = temp.newFolder("empty-cache")
|
||||||
|
|
||||||
|
cleanStalePastedImages(cacheDir)
|
||||||
|
|
||||||
|
assertTrue(cacheDir.listFiles()!!.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun startupSweepPreservesAnImportCreatedAfterTheStartupSnapshot() {
|
||||||
|
val cacheDir = temp.newFolder("live-cache")
|
||||||
|
val directory = cacheDir.resolve("pasted_images")
|
||||||
|
val startupTime = System.currentTimeMillis()
|
||||||
|
val imported = stagePastedImage(ByteArrayInputStream(byteArrayOf(1, 2)), directory, "png")!!
|
||||||
|
|
||||||
|
cleanStalePastedImages(cacheDir, startupTime)
|
||||||
|
|
||||||
|
assertArrayEquals(byteArrayOf(1, 2), imported.readBytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun exactLimitIsAcceptedWithItsActualByteCount() {
|
||||||
|
val dir = temp.newFolder("exact")
|
||||||
|
val bytes = byteArrayOf(1, 2, 3, 4)
|
||||||
|
|
||||||
|
val file = stagePastedImage(ByteArrayInputStream(bytes), dir, "png", maxBytes = 4)
|
||||||
|
|
||||||
|
assertArrayEquals(bytes, file!!.readBytes())
|
||||||
|
assertEquals(4L, file.length())
|
||||||
|
assertTrue(file.name.endsWith(".png"))
|
||||||
|
assertEquals(listOf(file), dir.listFiles()!!.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun callerLimitAbovePerFileCapStillRejectsAfterTenMiBAndCleansUp() {
|
||||||
|
val dir = temp.newFolder("per-file-cap")
|
||||||
|
val input = ByteArrayInputStream(ByteArray(10 * 1024 * 1024 + 1))
|
||||||
|
try {
|
||||||
|
stagePastedImage(input, dir, "png", maxBytes = 20L * 1024 * 1024)
|
||||||
|
fail("Expected the per-file limit")
|
||||||
|
} catch (_: PastedImageTooLargeException) {
|
||||||
|
assertTrue(dir.listFiles()!!.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unknownSizeIsBoundedByTheActualStreamAndReadsAtMostLimitPlusOne() {
|
||||||
|
val dir = temp.newFolder("oversize")
|
||||||
|
val input =
|
||||||
|
object : ByteArrayInputStream(ByteArray(100)) {
|
||||||
|
var consumed = 0
|
||||||
|
|
||||||
|
override fun read(
|
||||||
|
b: ByteArray,
|
||||||
|
off: Int,
|
||||||
|
len: Int,
|
||||||
|
): Int = super.read(b, off, len).also { if (it > 0) consumed += it }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
stagePastedImage(input, dir, "png", maxBytes = 4)
|
||||||
|
fail("Expected size limit rejection")
|
||||||
|
} catch (_: PastedImageTooLargeException) {
|
||||||
|
// The exact limit is accepted by the preceding test; only the extra byte rejects.
|
||||||
|
}
|
||||||
|
assertEquals(5, input.consumed)
|
||||||
|
assertTrue(dir.listFiles()!!.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptyStreamIsRejectedAndLeavesNoCacheFile() {
|
||||||
|
val dir = temp.newFolder("empty")
|
||||||
|
|
||||||
|
assertNull(stagePastedImage(ByteArrayInputStream(byteArrayOf()), dir, "webp", maxBytes = 4))
|
||||||
|
assertTrue(dir.listFiles()!!.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun streamFailureDeletesPartialFileAndClosesSource() {
|
||||||
|
val dir = temp.newFolder("broken")
|
||||||
|
val input =
|
||||||
|
object : InputStream() {
|
||||||
|
var reads = 0
|
||||||
|
var closed = false
|
||||||
|
|
||||||
|
override fun read(): Int = if (reads++ == 0) 7 else throw IOException("provider failed")
|
||||||
|
|
||||||
|
override fun read(
|
||||||
|
b: ByteArray,
|
||||||
|
off: Int,
|
||||||
|
len: Int,
|
||||||
|
): Int =
|
||||||
|
if (reads++ == 0) {
|
||||||
|
b[off] = 7
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
throw IOException("provider failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
closed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
stagePastedImage(input, dir, "png", maxBytes = 4)
|
||||||
|
fail("Expected stream failure")
|
||||||
|
} catch (_: IOException) {
|
||||||
|
assertTrue(input.closed)
|
||||||
|
assertTrue(dir.listFiles()!!.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun existingNameCollisionNeverTruncatesOrDeletesExistingFile() {
|
||||||
|
val dir = temp.newFolder("collision")
|
||||||
|
val id = UUID.randomUUID().toString()
|
||||||
|
val sentinel = dir.resolve("$id.png")
|
||||||
|
sentinel.writeText("keep me")
|
||||||
|
|
||||||
|
try {
|
||||||
|
stagePastedImage(ByteArrayInputStream(byteArrayOf(1)), dir, "png", newId = { id })
|
||||||
|
fail("Expected exclusive-create failure")
|
||||||
|
} catch (_: IOException) {
|
||||||
|
assertEquals("keep me", sentinel.readText())
|
||||||
|
assertEquals(listOf(sentinel), dir.listFiles()!!.toList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun simultaneousImportsUseDistinctFilesAndKeepTheirOwnBytes() {
|
||||||
|
val dir = temp.newFolder("concurrent")
|
||||||
|
val executor = Executors.newFixedThreadPool(8)
|
||||||
|
try {
|
||||||
|
val tasks =
|
||||||
|
(1..16).map { n ->
|
||||||
|
Callable {
|
||||||
|
val bytes = ByteArray(n) { n.toByte() }
|
||||||
|
val file = stagePastedImage(ByteArrayInputStream(bytes), dir, "jpeg", maxBytes = 64)!!
|
||||||
|
file to bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val results = executor.invokeAll(tasks).map { it.get(10, TimeUnit.SECONDS) }
|
||||||
|
|
||||||
|
assertEquals(16, results.map { it.first.name }.toSet().size)
|
||||||
|
results.forEach { (file, bytes) -> assertArrayEquals(bytes, file.readBytes()) }
|
||||||
|
assertEquals(16, dir.listFiles()!!.size)
|
||||||
|
} finally {
|
||||||
|
executor.shutdownNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cancellationCheckDeletesPartialFileAndPropagatesCancellation() {
|
||||||
|
val dir = temp.newFolder("cancel")
|
||||||
|
var checks = 0
|
||||||
|
val input = ByteArrayInputStream(ByteArray(100))
|
||||||
|
|
||||||
|
try {
|
||||||
|
stagePastedImage(
|
||||||
|
input,
|
||||||
|
dir,
|
||||||
|
"png",
|
||||||
|
maxBytes = 100,
|
||||||
|
checkActive = {
|
||||||
|
if (++checks == 2) throw CancellationException("cancelled")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
fail("Expected cancellation")
|
||||||
|
} catch (_: CancellationException) {
|
||||||
|
assertTrue(dir.listFiles()!!.isEmpty())
|
||||||
|
assertTrue(checks >= 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aDiscardedResultDeletesOnlyItsOwnedFile() {
|
||||||
|
val dir = temp.newFolder("discard")
|
||||||
|
val file = stagePastedImage(ByteArrayInputStream(byteArrayOf(1)), dir, "png")!!
|
||||||
|
val unrelated = dir.resolve("unrelated.png").apply { writeText("keep") }
|
||||||
|
val result =
|
||||||
|
PastedImage(
|
||||||
|
attachment =
|
||||||
|
com.m57.hermescontrol.data.model.Attachment(
|
||||||
|
uri = "content://example/file",
|
||||||
|
name = file.name,
|
||||||
|
mimeType = "image/png",
|
||||||
|
size = 1,
|
||||||
|
),
|
||||||
|
file = file,
|
||||||
|
)
|
||||||
|
|
||||||
|
result.discard()
|
||||||
|
assertFalse(file.exists())
|
||||||
|
assertTrue(unrelated.exists())
|
||||||
|
assertEquals("keep", unrelated.readText())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,29 @@ class ChatSendStoreTest {
|
|||||||
assertEquals(setOf("send-1"), confirmed)
|
assertEquals(setOf("send-1"), confirmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rowIdOnHistoryPageConfirmsAcceptedReceiptWithoutMergeMatch() {
|
||||||
|
val receipts =
|
||||||
|
listOf(
|
||||||
|
pending("send-1", "A").copy(userRowId = 334755L),
|
||||||
|
pending("send-2", "B").copy(userRowId = 999L),
|
||||||
|
pending("send-3", "C"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val confirmed = pendingSendIdsConfirmedByRowIds(setOf(334755L), receipts)
|
||||||
|
|
||||||
|
assertEquals(setOf("send-1"), confirmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rowIdReceiptIsNotDowngradedButIdlessReceiptIs() {
|
||||||
|
val withRow = pending("send-1", "A").copy(userRowId = 334755L)
|
||||||
|
val idless = pending("send-2", "B")
|
||||||
|
|
||||||
|
assertTrue(!canDemoteAcceptedReceipt(withRow))
|
||||||
|
assertTrue(canDemoteAcceptedReceipt(idless))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun durableAliasConsumesOnlyOneDuplicateReceiptOccurrence() {
|
fun durableAliasConsumesOnlyOneDuplicateReceiptOccurrence() {
|
||||||
val existing =
|
val existing =
|
||||||
|
|||||||
@@ -360,6 +360,14 @@ class ChatViewModelTest {
|
|||||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
// Typed redirects bypass send(); keep this fixture off the real singleton transport.
|
||||||
|
every { HermesWsClient.sendRedirect(any(), any(), any()) } answers {
|
||||||
|
reqCount++
|
||||||
|
val id = "req-redirect-$reqCount"
|
||||||
|
sentRequestMethods += WsMethods.SESSION_REDIRECT to id
|
||||||
|
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||||
|
id
|
||||||
|
}
|
||||||
every { HermesWsClient.respondToServerRequest(any(), any()) } returns true
|
every { HermesWsClient.respondToServerRequest(any(), any()) } returns true
|
||||||
every { HermesWsClient.respondToServerRequestError(any(), any(), any()) } returns true
|
every { HermesWsClient.respondToServerRequestError(any(), any(), any()) } returns true
|
||||||
|
|
||||||
@@ -396,6 +404,73 @@ class ChatViewModelTest {
|
|||||||
unmockkAll()
|
unmockkAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pastedImages_areAddedOnlyToCapturedReadySessionWithoutSubmitting() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val (viewModel, _) = createViewModelWithSession()
|
||||||
|
val target = checkNotNull(viewModel.captureAttachmentTarget())
|
||||||
|
val image = Attachment("content://test/pasted.png", "pasted.png", "image/png", 4)
|
||||||
|
val messagesBefore = viewModel.uiState.value.messages
|
||||||
|
assertTrue(viewModel.addPastedAttachments(target, listOf(image)))
|
||||||
|
runCurrent()
|
||||||
|
assertEquals(listOf(image), viewModel.uiState.value.pendingAttachments)
|
||||||
|
assertEquals(messagesBefore, viewModel.uiState.value.messages)
|
||||||
|
verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pastedImages_rejectLateCompletionAfterSwitchAwayAndBack() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val (viewModel, sessionId) = createViewModelWithSession()
|
||||||
|
val target = checkNotNull(viewModel.captureAttachmentTarget())
|
||||||
|
viewModel.switchSession("other-paste-test-session")
|
||||||
|
viewModel.switchSession(sessionId)
|
||||||
|
val image = Attachment("content://test/pasted.png", "pasted.png", "image/png", 4)
|
||||||
|
assertFalse(viewModel.addPastedAttachments(target, listOf(image)))
|
||||||
|
assertTrue(
|
||||||
|
viewModel.uiState.value.pendingAttachments
|
||||||
|
.isEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pastedImages_rejectChangedServerAndProfilesAndGeneration() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val (viewModel, _) = createViewModelWithSession()
|
||||||
|
val target = checkNotNull(viewModel.captureAttachmentTarget())
|
||||||
|
val image = Attachment("content://test/pasted.png", "pasted.png", "image/png", 4)
|
||||||
|
for (stale in listOf(
|
||||||
|
target.copy(baseUrl = "http://other-paste-test.local/"),
|
||||||
|
target.copy(connectionProfileId = "other-connection"),
|
||||||
|
target.copy(agentProfileId = "other-agent"),
|
||||||
|
target.copy(generation = target.generation + 1),
|
||||||
|
)) {
|
||||||
|
assertFalse(viewModel.addPastedAttachments(stale, listOf(image)))
|
||||||
|
}
|
||||||
|
assertTrue(
|
||||||
|
viewModel.uiState.value.pendingAttachments
|
||||||
|
.isEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pastedImages_cannotCaptureTargetBeforeReady() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertNull(viewModel.captureAttachmentTarget())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pastedImages_rejectDisconnectedSessionBeforeCombinedUiStateCatchesUp() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val (viewModel, _) = createViewModelWithSession()
|
||||||
|
val target = checkNotNull(viewModel.captureAttachmentTarget())
|
||||||
|
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||||
|
assertNull(viewModel.captureAttachmentTarget())
|
||||||
|
assertFalse(viewModel.addPastedAttachments(target, emptyList()))
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Create a ViewModel with the fake repo injected directly. */
|
/** Create a ViewModel with the fake repo injected directly. */
|
||||||
@@ -498,6 +573,32 @@ class ChatViewModelTest {
|
|||||||
assertEquals("op-1218", operation?.opId)
|
assertEquals("op-1218", operation?.opId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sessionResume_sendsDesktopSourceLikeSessionCreate() =
|
||||||
|
runTest {
|
||||||
|
// #1450: session.create declared source="desktop" but session.resume omitted it, so
|
||||||
|
// the gateway resolved the resumed runtime from its host env ("tui") and staged a
|
||||||
|
// bogus surface switch that also unloaded the desktop_ui toolset.
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val captured = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||||
|
every { HermesWsClient.send(any(), any(), any()) } answers {
|
||||||
|
val id = "req-${captured.size + 1}"
|
||||||
|
captured.add(arg<String>(0) to (arg<Map<String, Any>>(1)))
|
||||||
|
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.switchSession("stored-session")
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val resumeSent = captured.firstOrNull { it.first == WsMethods.SESSION_RESUME }
|
||||||
|
assertNotNull("session.resume should be dispatched", resumeSent)
|
||||||
|
assertEquals(JsonPrimitive("stored-session"), resumeSent!!.second["session_id"])
|
||||||
|
assertEquals(JsonPrimitive("desktop"), resumeSent.second["source"])
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun sessionResume_restoresRetainedFailureAsNonDurablePartial() =
|
fun sessionResume_restoresRetainedFailureAsNonDurablePartial() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -627,7 +728,7 @@ class ChatViewModelTest {
|
|||||||
val (viewModel, sessionId) = createViewModelWithSession()
|
val (viewModel, sessionId) = createViewModelWithSession()
|
||||||
|
|
||||||
every {
|
every {
|
||||||
HermesWsClient.request(WsMethods.COMMAND_DISPATCH, any(), any())
|
HermesWsClient.request(WsMethods.SLASH_EXEC, any(), any(), true)
|
||||||
} returns
|
} returns
|
||||||
CompletableDeferred(
|
CompletableDeferred(
|
||||||
mapOf("type" to "exec", "output" to "**Available Commands:**\n\u2022 `/status`\n\u2022 `/new`"),
|
mapOf("type" to "exec", "output" to "**Available Commands:**\n\u2022 `/status`\n\u2022 `/new`"),
|
||||||
@@ -1287,7 +1388,7 @@ class ChatViewModelTest {
|
|||||||
viewModel.sendMessage("/status")
|
viewModel.sendMessage("/status")
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
verify { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) }
|
verify { HermesWsClient.request(WsMethods.SLASH_EXEC, any(), any(), true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1298,7 +1399,7 @@ class ChatViewModelTest {
|
|||||||
viewModel.sendMessage("/sessions")
|
viewModel.sendMessage("/sessions")
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
verify { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) }
|
verify { HermesWsClient.request(WsMethods.SLASH_EXEC, any(), any(), true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1309,7 +1410,7 @@ class ChatViewModelTest {
|
|||||||
viewModel.sendMessage("/stats")
|
viewModel.sendMessage("/stats")
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
verify { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) }
|
verify { HermesWsClient.request(WsMethods.SLASH_EXEC, any(), any(), true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1635,6 +1736,7 @@ class ChatViewModelTest {
|
|||||||
JsonObject(
|
JsonObject(
|
||||||
mapOf(
|
mapOf(
|
||||||
"session_id" to JsonPrimitive("session-from-notification"),
|
"session_id" to JsonPrimitive("session-from-notification"),
|
||||||
|
"source" to JsonPrimitive("desktop"),
|
||||||
"omit_messages" to JsonPrimitive(true),
|
"omit_messages" to JsonPrimitive(true),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -5088,6 +5190,7 @@ class ChatViewModelTest {
|
|||||||
JsonObject(
|
JsonObject(
|
||||||
mapOf(
|
mapOf(
|
||||||
"session_id" to JsonPrimitive("session-456"),
|
"session_id" to JsonPrimitive("session-456"),
|
||||||
|
"source" to JsonPrimitive("desktop"),
|
||||||
"omit_messages" to JsonPrimitive(true),
|
"omit_messages" to JsonPrimitive(true),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -5943,6 +6046,7 @@ class ChatViewModelTest {
|
|||||||
JsonObject(
|
JsonObject(
|
||||||
mapOf(
|
mapOf(
|
||||||
"session_id" to JsonPrimitive("session-456"),
|
"session_id" to JsonPrimitive("session-456"),
|
||||||
|
"source" to JsonPrimitive("desktop"),
|
||||||
"omit_messages" to JsonPrimitive(true),
|
"omit_messages" to JsonPrimitive(true),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -10636,7 +10740,8 @@ class ChatViewModelTest {
|
|||||||
.last()
|
.last()
|
||||||
.content,
|
.content,
|
||||||
)
|
)
|
||||||
assertEquals(PendingSendState.UNKNOWN, store.all().single().state)
|
// #1427: a mismatched history row cannot retire the gateway-confirmed receipt.
|
||||||
|
assertEquals(PendingSendState.ACCEPTED, store.all().single().state)
|
||||||
assertTrue(vm.uiState.value.isSessionReady)
|
assertTrue(vm.uiState.value.isSessionReady)
|
||||||
assertTrue(vm.sendMessage("probe-second"))
|
assertTrue(vm.sendMessage("probe-second"))
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
@@ -10648,9 +10753,10 @@ class ChatViewModelTest {
|
|||||||
assertNull(vm.uiState.value.errorMessage)
|
assertNull(vm.uiState.value.errorMessage)
|
||||||
verify(exactly = 1) { HermesWsClient.sendMessage(session, "probe-second", any(), any()) }
|
verify(exactly = 1) { HermesWsClient.sendMessage(session, "probe-second", any(), any()) }
|
||||||
verify(exactly = 1) { HermesWsClient.sendMessage(session, "probe-first", any(), any()) }
|
verify(exactly = 1) { HermesWsClient.sendMessage(session, "probe-first", any(), any()) }
|
||||||
assertEquals(PendingSendState.UNKNOWN, store.all().single { it.text == "probe-first" }.state)
|
assertEquals(PendingSendState.ACCEPTED, store.all().single { it.text == "probe-first" }.state)
|
||||||
|
// The accepted row 10 remains visible separately from the unrelated history row 20.
|
||||||
assertEquals(
|
assertEquals(
|
||||||
1,
|
2,
|
||||||
vm.transcriptState.value.messages
|
vm.transcriptState.value.messages
|
||||||
.count { it.content == "probe-first" },
|
.count { it.content == "probe-first" },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -478,4 +478,27 @@ class FullBleedTurnsTest {
|
|||||||
assertEquals(setOf("u1", "a2"), matchedMessageIds(msgs, listOf(0, 2, 0)))
|
assertEquals(setOf("u1", "a2"), matchedMessageIds(msgs, listOf(0, 2, 0)))
|
||||||
assertEquals(emptySet<String>(), matchedMessageIds(msgs, listOf(9)))
|
assertEquals(emptySet<String>(), matchedMessageIds(msgs, listOf(9)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `react_to_message tool rows are hidden but other tools stay`() {
|
||||||
|
val messages =
|
||||||
|
listOf(
|
||||||
|
msg("u1", MessageRole.USER),
|
||||||
|
msg("react", MessageRole.TOOL).copy(toolName = "react_to_message"),
|
||||||
|
msg("term", MessageRole.TOOL).copy(toolName = "terminal"),
|
||||||
|
msg("a1", MessageRole.ASSISTANT),
|
||||||
|
)
|
||||||
|
val agent = groupIntoTurns(messages).filterIsInstance<ChatTurn.Agent>().single()
|
||||||
|
assertEquals(
|
||||||
|
listOf("term", "a1"),
|
||||||
|
agent.entries.map {
|
||||||
|
when (it) {
|
||||||
|
is AgentEntry.Prose -> it.message.id
|
||||||
|
is AgentEntry.ToolRow -> it.message.id
|
||||||
|
is AgentEntry.SystemEvent -> it.message.id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assertEquals(emptyList<SearchMatch>(), ChatSearchController().findMatches(messages.take(2), "react").matches)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.local.toEntity
|
||||||
|
import com.m57.hermescontrol.data.local.toUiModel
|
||||||
|
import com.m57.hermescontrol.data.model.SessionMessage
|
||||||
|
import com.m57.hermescontrol.data.ws.WsEvent
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** #1433: gateway notices are per-session transcript rows that keep their position. */
|
||||||
|
class GatewayNoticeTest {
|
||||||
|
private val compactedText = "✓ Context compaction complete — continuing turn..."
|
||||||
|
|
||||||
|
private fun status(
|
||||||
|
kind: String,
|
||||||
|
text: String?,
|
||||||
|
sessionId: String? = "s1",
|
||||||
|
) = WsEvent.StatusUpdate(
|
||||||
|
status = null,
|
||||||
|
data = mapOf("kind" to kind, "text" to text),
|
||||||
|
sessionId = sessionId,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun reduce(
|
||||||
|
state: ChatUiState,
|
||||||
|
event: WsEvent,
|
||||||
|
streaming: StreamingState = StreamingState(),
|
||||||
|
) = ChatWsEventReducer.reduce(state, streaming, event, currentSessionId = "s1")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sentinelMatchesEveryGatewayInterruptShape() {
|
||||||
|
listOf(
|
||||||
|
"[the reply degenerated into a repetition loop and was interrupted]",
|
||||||
|
"Operation interrupted.",
|
||||||
|
"Operation interrupted: waiting for the provider to recover (cycle 1/3).",
|
||||||
|
"Operation interrupted: retrying API call after error (retry 2/5).",
|
||||||
|
"Operation interrupted: handling API error (RateLimitError: slow down).",
|
||||||
|
"Operation interrupted: retrying empty response from model (retry 1/2).",
|
||||||
|
"Operation interrupted during retry (timeout, attempt 1/3).",
|
||||||
|
" Operation interrupted.\n",
|
||||||
|
).forEach { assertTrue(it, GatewayNotice.isInterruptSentinel(it)) }
|
||||||
|
listOf(
|
||||||
|
"Operation interrupted",
|
||||||
|
"Operation interrupted. Here is what I found so far",
|
||||||
|
"The operation was interrupted.",
|
||||||
|
"Operation interrupted: first line.\nsecond line.",
|
||||||
|
).forEach { assertFalse(it, GatewayNotice.isInterruptSentinel(it)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun compactedNoticeBecomesPersistedSystemRowAndClearsTailState() {
|
||||||
|
val user = ChatMessage(id = "u", role = MessageRole.USER, content = "go")
|
||||||
|
val state =
|
||||||
|
ChatUiState(
|
||||||
|
currentSessionId = "s1",
|
||||||
|
messages = listOf(user),
|
||||||
|
isCompressing = true,
|
||||||
|
compressionStatus = "🗜️ Compacting context",
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = reduce(state, status("compacted", compactedText))
|
||||||
|
|
||||||
|
assertFalse(result.state.isCompressing)
|
||||||
|
assertNull(result.state.compressionStatus)
|
||||||
|
val notice = result.state.messages.last()
|
||||||
|
assertEquals(MessageRole.SYSTEM, notice.role)
|
||||||
|
assertEquals(compactedText, notice.content)
|
||||||
|
assertTrue(result.effects.any { it is ReducerEffect.PersistMessage && it.message.id == notice.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun replayedNoticeInSameTurnDoesNotStack() {
|
||||||
|
val state =
|
||||||
|
ChatUiState(
|
||||||
|
currentSessionId = "s1",
|
||||||
|
messages = listOf(ChatMessage(role = MessageRole.USER, content = "go")),
|
||||||
|
)
|
||||||
|
val once = reduce(state, status("compacted", compactedText)).state
|
||||||
|
val twice = reduce(once, status("compacted", compactedText)).state
|
||||||
|
assertEquals(1, twice.messages.count { it.role == MessageRole.SYSTEM })
|
||||||
|
|
||||||
|
val nextTurn = twice.copy(messages = twice.messages + ChatMessage(role = MessageRole.USER, content = "again"))
|
||||||
|
val again = reduce(nextTurn, status("compacted", compactedText)).state
|
||||||
|
assertEquals(2, again.messages.count { it.role == MessageRole.SYSTEM })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun otherSessionsStatusUpdatesAreIgnored() {
|
||||||
|
val state = ChatUiState(currentSessionId = "s1")
|
||||||
|
listOf("compacting", "compacted", "goal", "warn").forEach { kind ->
|
||||||
|
val result = reduce(state, status(kind, "text for $kind", sessionId = "other"))
|
||||||
|
assertEquals(kind, state, result.state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun durableKindsAppendAndTransientKindsDoNot() {
|
||||||
|
val state = ChatUiState(currentSessionId = "s1")
|
||||||
|
listOf("goal", "loop", "warn", "fallback").forEach { kind ->
|
||||||
|
assertEquals(kind, 1, reduce(state, status(kind, "✓ $kind")).state.messages.size)
|
||||||
|
}
|
||||||
|
listOf("status", "lifecycle", "process", "heartbeat", "ready").forEach { kind ->
|
||||||
|
assertTrue(kind, reduce(state, status(kind, "x")).state.messages.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun repetitionLoopCompletionDropsLoopedPartialAndAppendsNotice() {
|
||||||
|
val sealed = ChatMessage(id = "looped", role = MessageRole.ASSISTANT, content = "again again again")
|
||||||
|
val state = ChatUiState(currentSessionId = "s1", messages = listOf(sealed), isAgentTyping = true)
|
||||||
|
val event =
|
||||||
|
WsEvent.MessageComplete(
|
||||||
|
text = GatewayNotice.REPETITION_LOOP_INTERRUPTED,
|
||||||
|
sessionId = "s1",
|
||||||
|
rawPayload = mapOf("status" to "interrupted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = reduce(state, event, StreamingState(interruptedMessage = sealed))
|
||||||
|
|
||||||
|
assertEquals(listOf(MessageRole.SYSTEM), result.state.messages.map { it.role })
|
||||||
|
assertEquals(
|
||||||
|
GatewayNotice.REPETITION_LOOP_INTERRUPTED,
|
||||||
|
result.state.messages
|
||||||
|
.single()
|
||||||
|
.content,
|
||||||
|
)
|
||||||
|
assertFalse(result.state.isAgentTyping)
|
||||||
|
assertTrue(result.effects.contains(ReducerEffect.DeleteLocalMessage("looped")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun operationInterruptedCompletionKeepsRealPartial() {
|
||||||
|
val stream = ChatMessage(id = "p", role = MessageRole.ASSISTANT, content = "half an answer", isStreaming = true)
|
||||||
|
val state = ChatUiState(currentSessionId = "s1")
|
||||||
|
val event = WsEvent.MessageComplete(text = "Operation interrupted.", sessionId = "s1")
|
||||||
|
|
||||||
|
val result = reduce(state, event, StreamingState(streamingMessage = stream))
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf("p", null),
|
||||||
|
result.state.messages.map {
|
||||||
|
if (it.role ==
|
||||||
|
MessageRole.SYSTEM
|
||||||
|
) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
it.id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assertFalse(
|
||||||
|
result.state.messages
|
||||||
|
.first()
|
||||||
|
.isStreaming,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"Operation interrupted.",
|
||||||
|
result.state.messages
|
||||||
|
.last()
|
||||||
|
.content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun persistedSentinelRowMapsToSystemNoticeAndMergesWithLiveCopy() {
|
||||||
|
val rows =
|
||||||
|
listOf(
|
||||||
|
SessionMessage(id = 1, role = "user", content = JsonPrimitive("go")),
|
||||||
|
SessionMessage(id = 2, role = "assistant", content = JsonPrimitive("Operation interrupted.")),
|
||||||
|
SessionMessage(id = 3, role = "user", content = JsonPrimitive("next")),
|
||||||
|
SessionMessage(id = 4, role = "assistant", content = JsonPrimitive("done")),
|
||||||
|
)
|
||||||
|
val page = mapServerMessages("s", rows, 0, true, emptyList())
|
||||||
|
assertEquals(MessageRole.SYSTEM, page[1].role)
|
||||||
|
|
||||||
|
// Live view after the interrupted turn: the notice sits between the two turns.
|
||||||
|
val live =
|
||||||
|
listOf(
|
||||||
|
ChatMessage(id = "u1", role = MessageRole.USER, content = "go"),
|
||||||
|
ChatMessage(id = "n", role = MessageRole.SYSTEM, content = "Operation interrupted."),
|
||||||
|
ChatMessage(id = "u2", role = MessageRole.USER, content = "next"),
|
||||||
|
ChatMessage(id = "a2", role = MessageRole.ASSISTANT, content = "done"),
|
||||||
|
)
|
||||||
|
val merged = mergeTranscriptWithLive(page, live, preserveLiveIds = true)
|
||||||
|
assertEquals(
|
||||||
|
listOf("go", "Operation interrupted.", "next", "done"),
|
||||||
|
merged.map { it.content },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun liveOnlyNoticeStaysInPlaceAcrossLaterTurnsAndRefresh() {
|
||||||
|
val page =
|
||||||
|
mapServerMessages(
|
||||||
|
"s",
|
||||||
|
listOf(
|
||||||
|
SessionMessage(id = 1, role = "user", content = JsonPrimitive("first")),
|
||||||
|
SessionMessage(id = 2, role = "assistant", content = JsonPrimitive("one")),
|
||||||
|
SessionMessage(id = 3, role = "user", content = JsonPrimitive("second")),
|
||||||
|
SessionMessage(id = 4, role = "assistant", content = JsonPrimitive("two")),
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
true,
|
||||||
|
emptyList(),
|
||||||
|
)
|
||||||
|
val live =
|
||||||
|
listOf(
|
||||||
|
ChatMessage(id = "u1", role = MessageRole.USER, content = "first"),
|
||||||
|
ChatMessage(id = "a1", role = MessageRole.ASSISTANT, content = "one"),
|
||||||
|
ChatMessage(id = "c", role = MessageRole.SYSTEM, content = compactedText),
|
||||||
|
ChatMessage(id = "u2", role = MessageRole.USER, content = "second"),
|
||||||
|
ChatMessage(id = "a2", role = MessageRole.ASSISTANT, content = "two"),
|
||||||
|
)
|
||||||
|
val once = mergeTranscriptWithLive(page, live, preserveLiveIds = true)
|
||||||
|
val twice = mergeTranscriptWithLive(page, once, preserveLiveIds = true)
|
||||||
|
val expected = listOf("first", "one", compactedText, "second", "two")
|
||||||
|
assertEquals(expected, once.map { it.content })
|
||||||
|
assertEquals(expected, twice.map { it.content })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun roomRoundTripNormalizesLegacyAssistantSentinel() {
|
||||||
|
val legacy = ChatMessage(id = "x", role = MessageRole.ASSISTANT, content = "Operation interrupted.")
|
||||||
|
val restored = legacy.toEntity("s").toUiModel()
|
||||||
|
assertEquals(MessageRole.SYSTEM, restored.role)
|
||||||
|
val prose = ChatMessage(id = "y", role = MessageRole.ASSISTANT, content = "fine")
|
||||||
|
assertEquals(MessageRole.ASSISTANT, prose.toEntity("s").toUiModel().role)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,7 +161,7 @@ class SlashCommandDispatchRpcTest {
|
|||||||
// can end up holding session.usage instead of command.dispatch (CI run
|
// can end up holding session.usage instead of command.dispatch (CI run
|
||||||
// 31137581335 on 4645a2b: slash-focus flake).
|
// 31137581335 on 4645a2b: slash-focus flake).
|
||||||
// Specific per-test request() stubs registered later take precedence.
|
// Specific per-test request() stubs registered later take precedence.
|
||||||
every { HermesWsClient.request(any(), any(), any()) } answers {
|
every { HermesWsClient.request(any(), any(), any(), any()) } answers {
|
||||||
HermesWsClient.send(arg(0), arg(1)) {}
|
HermesWsClient.send(arg(0), arg(1)) {}
|
||||||
CompletableDeferred<Any?>(Unit)
|
CompletableDeferred<Any?>(Unit)
|
||||||
}
|
}
|
||||||
@@ -194,7 +194,7 @@ class SlashCommandDispatchRpcTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `non-hardcoded slash command forwards name arg session_id to COMMAND_DISPATCH`() =
|
fun `non-hardcoded slash command forwards command and session_id to SLASH_EXEC`() =
|
||||||
runTest {
|
runTest {
|
||||||
val (vm, sessionId) = createViewModelWithSession()
|
val (vm, sessionId) = createViewModelWithSession()
|
||||||
|
|
||||||
@@ -202,7 +202,7 @@ class SlashCommandDispatchRpcTest {
|
|||||||
val paramsCalls = mutableListOf<Map<String, Any>>()
|
val paramsCalls = mutableListOf<Map<String, Any>>()
|
||||||
var captured = false
|
var captured = false
|
||||||
every {
|
every {
|
||||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any(), any())
|
||||||
} answers {
|
} answers {
|
||||||
captured = true
|
captured = true
|
||||||
CompletableDeferred<Any?>(Unit)
|
CompletableDeferred<Any?>(Unit)
|
||||||
@@ -216,14 +216,67 @@ class SlashCommandDispatchRpcTest {
|
|||||||
// Captures hold ALL requests — the create flow's session.usage /
|
// Captures hold ALL requests — the create flow's session.usage /
|
||||||
// context_breakdown can land after the dispatch one (capture race,
|
// context_breakdown can land after the dispatch one (capture race,
|
||||||
// CI 2026-08-06 + 2026-08-07). Find the record, don't trust "last".
|
// CI 2026-08-06 + 2026-08-07). Find the record, don't trust "last".
|
||||||
val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH)
|
val execIndex = methodCalls.indexOf(WsMethods.SLASH_EXEC)
|
||||||
assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0)
|
assertTrue("expected SLASH_EXEC, got $methodCalls", execIndex >= 0)
|
||||||
val params = paramsCalls[dispatchIndex]
|
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls)
|
||||||
assertEquals(JsonPrimitive("help"), params["name"])
|
val params = paramsCalls[execIndex]
|
||||||
assertEquals(JsonPrimitive(""), params["arg"])
|
assertEquals(JsonPrimitive("help"), params["command"])
|
||||||
assertEquals(JsonPrimitive(sessionId), params["session_id"])
|
assertEquals(JsonPrimitive(sessionId), params["session_id"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `usage renders warning and suppresses intermediate error events`() =
|
||||||
|
runTest {
|
||||||
|
val (vm, _) = createViewModelWithSession()
|
||||||
|
every { HermesWsClient.request(WsMethods.SLASH_EXEC, any(), any(), true) } returns
|
||||||
|
CompletableDeferred<Any?>(mapOf("output" to "Session Token Usage", "warning" to "estimated"))
|
||||||
|
|
||||||
|
vm.sendMessage("/usage")
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
verify(exactly = 1) { HermesWsClient.request(WsMethods.SLASH_EXEC, any(), any(), true) }
|
||||||
|
verify(exactly = 0) { HermesWsClient.request(WsMethods.COMMAND_DISPATCH, any(), any(), any()) }
|
||||||
|
assertEquals(
|
||||||
|
"warning: estimated\nSession Token Usage",
|
||||||
|
vm.uiState.value.messages
|
||||||
|
.last()
|
||||||
|
.content,
|
||||||
|
)
|
||||||
|
assertNull(vm.uiState.value.errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `slash failure falls back with name arg and suppresses both error events`() =
|
||||||
|
runTest {
|
||||||
|
val (vm, sessionId) = createViewModelWithSession()
|
||||||
|
val calls = mutableListOf<String>()
|
||||||
|
val params = mutableListOf<Map<String, Any>>()
|
||||||
|
every { HermesWsClient.request(capture(calls), capture(params), any(), true) } answers {
|
||||||
|
if (arg<String>(0) == WsMethods.SLASH_EXEC) {
|
||||||
|
CompletableDeferred<Any?>().apply {
|
||||||
|
completeExceptionally(HermesWsClient.HermesRpcException("worker unavailable"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CompletableDeferred<Any?>(mapOf("type" to "plugin", "output" to "plugin result"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vm.sendMessage("/custom some args")
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(listOf(WsMethods.SLASH_EXEC, WsMethods.COMMAND_DISPATCH), calls)
|
||||||
|
assertEquals(JsonPrimitive("custom"), params[1]["name"])
|
||||||
|
assertEquals(JsonPrimitive("some args"), params[1]["arg"])
|
||||||
|
assertEquals(JsonPrimitive(sessionId), params[1]["session_id"])
|
||||||
|
assertEquals(
|
||||||
|
"plugin result",
|
||||||
|
vm.uiState.value.messages
|
||||||
|
.last()
|
||||||
|
.content,
|
||||||
|
)
|
||||||
|
assertNull(vm.uiState.value.errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `slash queue submits the arg with queued=true instead of command dispatch`() =
|
fun `slash queue submits the arg with queued=true instead of command dispatch`() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -235,10 +288,16 @@ class SlashCommandDispatchRpcTest {
|
|||||||
val sentText = slot<String>()
|
val sentText = slot<String>()
|
||||||
val sentQueued = slot<Boolean>()
|
val sentQueued = slot<Boolean>()
|
||||||
every {
|
every {
|
||||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any(), any())
|
||||||
} answers {
|
} answers {
|
||||||
|
if (arg<String>(0) == WsMethods.SLASH_EXEC) {
|
||||||
|
CompletableDeferred<Any?>().apply {
|
||||||
|
completeExceptionally(HermesWsClient.HermesRpcException("slash unavailable"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
CompletableDeferred<Any?>(Unit)
|
CompletableDeferred<Any?>(Unit)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
every {
|
every {
|
||||||
HermesWsClient.sendMessage(
|
HermesWsClient.sendMessage(
|
||||||
capture(sentSession),
|
capture(sentSession),
|
||||||
@@ -372,10 +431,16 @@ class SlashCommandDispatchRpcTest {
|
|||||||
val rpcMethods = mutableListOf<String>()
|
val rpcMethods = mutableListOf<String>()
|
||||||
var sendCalls = 0
|
var sendCalls = 0
|
||||||
every {
|
every {
|
||||||
HermesWsClient.request(capture(rpcMethods), any(), any())
|
HermesWsClient.request(capture(rpcMethods), any(), any(), any())
|
||||||
} answers {
|
} answers {
|
||||||
|
if (arg<String>(0) == WsMethods.SLASH_EXEC) {
|
||||||
|
CompletableDeferred<Any?>().apply {
|
||||||
|
completeExceptionally(HermesWsClient.HermesRpcException("slash unavailable"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
CompletableDeferred<Any?>(Unit)
|
CompletableDeferred<Any?>(Unit)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers {
|
every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers {
|
||||||
sendCalls++
|
sendCalls++
|
||||||
"queued-msg-1"
|
"queued-msg-1"
|
||||||
@@ -416,12 +481,12 @@ class SlashCommandDispatchRpcTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `non-registry-miss backend error is surfaced directly (no fallback)`() =
|
fun `actionable fallback error is surfaced`() =
|
||||||
runTest {
|
runTest {
|
||||||
val (vm, _) = createViewModelWithSession()
|
val (vm, _) = createViewModelWithSession()
|
||||||
|
|
||||||
every {
|
every {
|
||||||
HermesWsClient.request(any(), any(), any())
|
HermesWsClient.request(any(), any(), any(), any())
|
||||||
} answers {
|
} answers {
|
||||||
val d = CompletableDeferred<Any?>()
|
val d = CompletableDeferred<Any?>()
|
||||||
d.completeExceptionally(
|
d.completeExceptionally(
|
||||||
@@ -442,14 +507,14 @@ class SlashCommandDispatchRpcTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `registry-miss 4018 on command dispatch falls back to slash_exec and surfaces output`() =
|
fun `slash exec success surfaces output without command dispatch`() =
|
||||||
runTest {
|
runTest {
|
||||||
val (vm, sessionId) = createViewModelWithSession()
|
val (vm, sessionId) = createViewModelWithSession()
|
||||||
|
|
||||||
val methodCalls = mutableListOf<String>()
|
val methodCalls = mutableListOf<String>()
|
||||||
val paramsCalls = mutableListOf<Map<String, Any>>()
|
val paramsCalls = mutableListOf<Map<String, Any>>()
|
||||||
every {
|
every {
|
||||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any(), any())
|
||||||
} answers {
|
} answers {
|
||||||
// Read the method from THIS invocation, not from the shared
|
// Read the method from THIS invocation, not from the shared
|
||||||
// capture list: a concurrent request() landing between capture
|
// capture list: a concurrent request() landing between capture
|
||||||
@@ -474,16 +539,14 @@ class SlashCommandDispatchRpcTest {
|
|||||||
vm.sendMessage("/status")
|
vm.sendMessage("/status")
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// The fallback must have hit slash.exec with the full command string.
|
// Successful slash.exec must not need a fallback.
|
||||||
// Captures hold ALL requests — find the records instead of trusting
|
// Captures hold ALL requests — find the records instead of trusting
|
||||||
// "last" (create-flow session.usage can land after; capture race).
|
// "last" (create-flow session.usage can land after; capture race).
|
||||||
val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH)
|
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls)
|
||||||
assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0)
|
|
||||||
val execIndex = methodCalls.indexOf(WsMethods.SLASH_EXEC)
|
val execIndex = methodCalls.indexOf(WsMethods.SLASH_EXEC)
|
||||||
assertTrue("expected SLASH_EXEC fallback, got $methodCalls", execIndex >= 0)
|
assertTrue("expected SLASH_EXEC, got $methodCalls", execIndex >= 0)
|
||||||
assertTrue("dispatch must precede fallback", dispatchIndex < execIndex)
|
|
||||||
val params = paramsCalls[execIndex]
|
val params = paramsCalls[execIndex]
|
||||||
assertEquals(JsonPrimitive("/status"), params["command"])
|
assertEquals(JsonPrimitive("status"), params["command"])
|
||||||
assertEquals(JsonPrimitive(sessionId), params["session_id"])
|
assertEquals(JsonPrimitive(sessionId), params["session_id"])
|
||||||
|
|
||||||
// And the slash.exec output must be surfaced to the user.
|
// And the slash.exec output must be surfaced to the user.
|
||||||
@@ -500,10 +563,16 @@ class SlashCommandDispatchRpcTest {
|
|||||||
|
|
||||||
val rpcMethods = mutableListOf<String>()
|
val rpcMethods = mutableListOf<String>()
|
||||||
every {
|
every {
|
||||||
HermesWsClient.request(capture(rpcMethods), any(), any())
|
HermesWsClient.request(capture(rpcMethods), any(), any(), any())
|
||||||
} answers {
|
} answers {
|
||||||
|
if (arg<String>(0) == WsMethods.SLASH_EXEC) {
|
||||||
|
CompletableDeferred<Any?>().apply {
|
||||||
|
completeExceptionally(HermesWsClient.HermesRpcException("slash unavailable"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
CompletableDeferred<Any?>(Unit)
|
CompletableDeferred<Any?>(Unit)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// /redraw is TUI-only (issue #574) — hidden from suggestions but a
|
// /redraw is TUI-only (issue #574) — hidden from suggestions but a
|
||||||
// user can still type it. It must be blocked before any RPC fires.
|
// user can still type it. It must be blocked before any RPC fires.
|
||||||
@@ -533,12 +602,12 @@ class SlashCommandDispatchRpcTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `slash_exec double-fault surfaces the secondary error`() =
|
fun `fallback registry miss preserves original slash exec error`() =
|
||||||
runTest {
|
runTest {
|
||||||
val (vm, _) = createViewModelWithSession()
|
val (vm, _) = createViewModelWithSession()
|
||||||
|
|
||||||
every {
|
every {
|
||||||
HermesWsClient.request(any(), any(), any())
|
HermesWsClient.request(any(), any(), any(), any())
|
||||||
} answers {
|
} answers {
|
||||||
val m = arg<String>(0)
|
val m = arg<String>(0)
|
||||||
val d = CompletableDeferred<Any?>()
|
val d = CompletableDeferred<Any?>()
|
||||||
@@ -561,7 +630,7 @@ class SlashCommandDispatchRpcTest {
|
|||||||
vm.sendMessage("/status")
|
vm.sendMessage("/status")
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// Both RPCs fail -> the secondary slash.exec error must surface.
|
// A registry miss must not mask the original worker failure.
|
||||||
val last =
|
val last =
|
||||||
vm.uiState.value.messages
|
vm.uiState.value.messages
|
||||||
.lastOrNull()
|
.lastOrNull()
|
||||||
@@ -569,12 +638,12 @@ class SlashCommandDispatchRpcTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `slash_exec blank output appends no assistant message`() =
|
fun `slash_exec blank output reports no output like desktop`() =
|
||||||
runTest {
|
runTest {
|
||||||
val (vm, _) = createViewModelWithSession()
|
val (vm, _) = createViewModelWithSession()
|
||||||
|
|
||||||
every {
|
every {
|
||||||
HermesWsClient.request(any(), any(), any())
|
HermesWsClient.request(any(), any(), any(), any())
|
||||||
} answers {
|
} answers {
|
||||||
val m = arg<String>(0)
|
val m = arg<String>(0)
|
||||||
val d = CompletableDeferred<Any?>()
|
val d = CompletableDeferred<Any?>()
|
||||||
@@ -595,12 +664,10 @@ class SlashCommandDispatchRpcTest {
|
|||||||
vm.sendMessage("/status")
|
vm.sendMessage("/status")
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// The user's "/status" message is added, but blank slash.exec output
|
// Desktop reports an explicit no-output notice rather than silently succeeding.
|
||||||
// must NOT append an assistant bubble — so the last message is still
|
assertEquals(before + 2, vm.uiState.value.messages.size)
|
||||||
// the user's own command, and only one message was added.
|
|
||||||
assertEquals(before + 1, vm.uiState.value.messages.size)
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"/status",
|
"/status: no output",
|
||||||
vm.uiState.value.messages
|
vm.uiState.value.messages
|
||||||
.lastOrNull()
|
.lastOrNull()
|
||||||
?.content,
|
?.content,
|
||||||
@@ -615,10 +682,16 @@ class SlashCommandDispatchRpcTest {
|
|||||||
val methodCalls = mutableListOf<String>()
|
val methodCalls = mutableListOf<String>()
|
||||||
val paramsCalls = mutableListOf<Map<String, Any>>()
|
val paramsCalls = mutableListOf<Map<String, Any>>()
|
||||||
every {
|
every {
|
||||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any(), any())
|
||||||
} answers {
|
} answers {
|
||||||
|
if (arg<String>(0) == WsMethods.SLASH_EXEC) {
|
||||||
|
CompletableDeferred<Any?>().apply {
|
||||||
|
completeExceptionally(HermesWsClient.HermesRpcException("slash unavailable"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
CompletableDeferred<Any?>(Unit)
|
CompletableDeferred<Any?>(Unit)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// /init generates-or-updates AGENTS.md (backend command.dispatch
|
// /init generates-or-updates AGENTS.md (backend command.dispatch
|
||||||
// name=="init" branch, hermes-agent tui_gateway/server.py ~L15451).
|
// name=="init" branch, hermes-agent tui_gateway/server.py ~L15451).
|
||||||
@@ -643,10 +716,16 @@ class SlashCommandDispatchRpcTest {
|
|||||||
val methodCalls = mutableListOf<String>()
|
val methodCalls = mutableListOf<String>()
|
||||||
val paramsCalls = mutableListOf<Map<String, Any>>()
|
val paramsCalls = mutableListOf<Map<String, Any>>()
|
||||||
every {
|
every {
|
||||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any(), any())
|
||||||
} answers {
|
} answers {
|
||||||
|
if (arg<String>(0) == WsMethods.SLASH_EXEC) {
|
||||||
|
CompletableDeferred<Any?>().apply {
|
||||||
|
completeExceptionally(HermesWsClient.HermesRpcException("slash unavailable"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
CompletableDeferred<Any?>(Unit)
|
CompletableDeferred<Any?>(Unit)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
vm.sendMessage("/init extra context here")
|
vm.sendMessage("/init extra context here")
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
@@ -668,10 +747,16 @@ class SlashCommandDispatchRpcTest {
|
|||||||
val methodCalls = mutableListOf<String>()
|
val methodCalls = mutableListOf<String>()
|
||||||
val paramsCalls = mutableListOf<Map<String, Any>>()
|
val paramsCalls = mutableListOf<Map<String, Any>>()
|
||||||
every {
|
every {
|
||||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any(), any())
|
||||||
} answers {
|
} answers {
|
||||||
|
if (arg<String>(0) == WsMethods.SLASH_EXEC) {
|
||||||
|
CompletableDeferred<Any?>().apply {
|
||||||
|
completeExceptionally(HermesWsClient.HermesRpcException("slash unavailable"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
CompletableDeferred<Any?>(Unit)
|
CompletableDeferred<Any?>(Unit)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// /focus is the display-only focus view (backend command.dispatch
|
// /focus is the display-only focus view (backend command.dispatch
|
||||||
// name=="focus" branch, hermes-agent tui_gateway/server.py ~L15522).
|
// name=="focus" branch, hermes-agent tui_gateway/server.py ~L15522).
|
||||||
@@ -696,10 +781,16 @@ class SlashCommandDispatchRpcTest {
|
|||||||
val methodCalls = mutableListOf<String>()
|
val methodCalls = mutableListOf<String>()
|
||||||
val paramsCalls = mutableListOf<Map<String, Any>>()
|
val paramsCalls = mutableListOf<Map<String, Any>>()
|
||||||
every {
|
every {
|
||||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any(), any())
|
||||||
} answers {
|
} answers {
|
||||||
|
if (arg<String>(0) == WsMethods.SLASH_EXEC) {
|
||||||
|
CompletableDeferred<Any?>().apply {
|
||||||
|
completeExceptionally(HermesWsClient.HermesRpcException("slash unavailable"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
CompletableDeferred<Any?>(Unit)
|
CompletableDeferred<Any?>(Unit)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
vm.sendMessage("/focus on")
|
vm.sendMessage("/focus on")
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
@@ -714,7 +805,7 @@ class SlashCommandDispatchRpcTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `command dispatch type=send submits the returned prompt (init)`() =
|
fun `slash exec type=send submits the returned prompt (init)`() =
|
||||||
runTest {
|
runTest {
|
||||||
val (vm, _) = createViewModelWithSession()
|
val (vm, _) = createViewModelWithSession()
|
||||||
|
|
||||||
@@ -722,12 +813,12 @@ class SlashCommandDispatchRpcTest {
|
|||||||
val paramsCalls = mutableListOf<Map<String, Any>>()
|
val paramsCalls = mutableListOf<Map<String, Any>>()
|
||||||
val sentText = slot<String>()
|
val sentText = slot<String>()
|
||||||
every {
|
every {
|
||||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any(), any())
|
||||||
} answers {
|
} answers {
|
||||||
val d = CompletableDeferred<Any?>()
|
val d = CompletableDeferred<Any?>()
|
||||||
// Read the method from THIS invocation, not from the shared
|
// Read the method from THIS invocation, not from the shared
|
||||||
// capture list (see the 4018 test above).
|
// capture list (see the 4018 test above).
|
||||||
if (arg<String>(0) == WsMethods.COMMAND_DISPATCH) {
|
if (arg<String>(0) == WsMethods.SLASH_EXEC) {
|
||||||
// Backend /init returns type:"send" with the AGENTS.md prompt.
|
// Backend /init returns type:"send" with the AGENTS.md prompt.
|
||||||
d.complete(mapOf("type" to "send", "message" to "Scan this repo and write AGENTS.md"))
|
d.complete(mapOf("type" to "send", "message" to "Scan this repo and write AGENTS.md"))
|
||||||
} else {
|
} else {
|
||||||
@@ -766,7 +857,7 @@ class SlashCommandDispatchRpcTest {
|
|||||||
val (vm, _) = createViewModelWithSession()
|
val (vm, _) = createViewModelWithSession()
|
||||||
|
|
||||||
every {
|
every {
|
||||||
HermesWsClient.request(any(), any(), any())
|
HermesWsClient.request(any(), any(), any(), any())
|
||||||
} answers {
|
} answers {
|
||||||
val m = arg<String>(0)
|
val m = arg<String>(0)
|
||||||
val d = CompletableDeferred<Any?>()
|
val d = CompletableDeferred<Any?>()
|
||||||
@@ -774,7 +865,7 @@ class SlashCommandDispatchRpcTest {
|
|||||||
// Backend /focus returns type:"exec" with a notice line.
|
// Backend /focus returns type:"exec" with a notice line.
|
||||||
d.complete(mapOf("type" to "exec", "output" to "Focus view: ON (tool progress pinned off)"))
|
d.complete(mapOf("type" to "exec", "output" to "Focus view: ON (tool progress pinned off)"))
|
||||||
} else {
|
} else {
|
||||||
d.complete(mapOf("session_id" to "session-xyz"))
|
d.completeExceptionally(HermesWsClient.HermesRpcException("slash unavailable"))
|
||||||
}
|
}
|
||||||
d
|
d
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.local.toEntity
|
||||||
|
import com.m57.hermescontrol.data.local.toUiModel
|
||||||
|
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||||
|
import com.m57.hermescontrol.data.model.SessionMessage
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonArray
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import kotlinx.serialization.json.putJsonObject
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression tests for user image hydration:
|
||||||
|
* - Direct unit tests for [userImageAttachments]
|
||||||
|
* - Integration via [mapServerMessages] with USER turns
|
||||||
|
*/
|
||||||
|
class UserImageHydrationTest {
|
||||||
|
@Test
|
||||||
|
fun restHydrationEnrichesCachedUserWithoutReplacingLocalAttachments() {
|
||||||
|
val rest =
|
||||||
|
mapServerMessages(
|
||||||
|
sessionId = "session-1",
|
||||||
|
messages =
|
||||||
|
listOf(
|
||||||
|
SessionMessage(id = 42, role = "user", content = JsonPrimitive("caption\n@image:/image.png")),
|
||||||
|
),
|
||||||
|
offset = 0,
|
||||||
|
latestPaging = true,
|
||||||
|
liveMessages = emptyList(),
|
||||||
|
mediaUrl = { "https://gateway.test/image.png" },
|
||||||
|
).single()
|
||||||
|
val cached =
|
||||||
|
rest.copy(
|
||||||
|
attachments = null,
|
||||||
|
content = "[{\"image_url\":\"data:image/png;base64,old\"}]",
|
||||||
|
isHistoricalCache = true,
|
||||||
|
)
|
||||||
|
val hydrated = mergeTranscriptWithLive(listOf(rest), listOf(cached)).single()
|
||||||
|
assertEquals(rest.attachments, hydrated.attachments)
|
||||||
|
assertEquals(rest.content, hydrated.content)
|
||||||
|
assertEquals(cached.id, hydrated.id)
|
||||||
|
val localAttachments =
|
||||||
|
listOf(
|
||||||
|
com.m57.hermescontrol.data.model
|
||||||
|
.Attachment("content://local/image", "image.png", "image/png"),
|
||||||
|
)
|
||||||
|
val local = rest.copy(attachments = localAttachments)
|
||||||
|
assertEquals(localAttachments, mergeTranscriptWithLive(listOf(rest), listOf(local)).single().attachments)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun roomRoundTripKeepsReferencesForGatewayReconstruction() {
|
||||||
|
val content = "caption\n@image:/image.png"
|
||||||
|
val message = ChatMessage(id = "cached", role = MessageRole.USER, content = content)
|
||||||
|
val restored = message.toEntity("session-1").toUiModel()
|
||||||
|
assertEquals(content, restored.content)
|
||||||
|
val attachments = userImageAttachments(restored.content) { "https://current-gateway.test$it" }
|
||||||
|
assertEquals("https://current-gateway.test/image.png", attachments.single().gatewayUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun userImageAttachments_recognizesTrimmedWholeLineImageDirectiveWithAbsolutePaths() {
|
||||||
|
val content =
|
||||||
|
"""
|
||||||
|
Here is my photo
|
||||||
|
@image:/absolute/path/to/cat.png
|
||||||
|
And another one
|
||||||
|
@image:/absolute/path/to/dog.jpg
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val attachments =
|
||||||
|
userImageAttachments(content) { path -> "https://gateway.local/api/files/download?path=$path" }
|
||||||
|
|
||||||
|
assertEquals(2, attachments.size)
|
||||||
|
|
||||||
|
val first = attachments[0]
|
||||||
|
assertEquals("https://gateway.local/api/files/download?path=/absolute/path/to/cat.png", first.uri)
|
||||||
|
assertEquals("cat.png", first.name)
|
||||||
|
assertEquals("image/png", first.mimeType)
|
||||||
|
assertEquals(0L, first.size)
|
||||||
|
assertEquals("https://gateway.local/api/files/download?path=/absolute/path/to/cat.png", first.gatewayUrl)
|
||||||
|
assertEquals(AttachmentSource.GATEWAY, first.source)
|
||||||
|
assertTrue(first.isImage)
|
||||||
|
|
||||||
|
val second = attachments[1]
|
||||||
|
assertEquals("https://gateway.local/api/files/download?path=/absolute/path/to/dog.jpg", second.uri)
|
||||||
|
assertEquals("dog.jpg", second.name)
|
||||||
|
assertEquals("image/jpeg", second.mimeType)
|
||||||
|
assertEquals(AttachmentSource.GATEWAY, second.source)
|
||||||
|
assertTrue(second.isImage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun userImageAttachments_allowsSpacesInAbsolutePaths() {
|
||||||
|
val content = "@image:/Users/test user/My Documents/holiday photo 2026.png"
|
||||||
|
|
||||||
|
val attachments =
|
||||||
|
userImageAttachments(content) { path -> "https://gateway.local/api/files/download?path=$path" }
|
||||||
|
|
||||||
|
assertEquals(1, attachments.size)
|
||||||
|
val attachment = attachments.single()
|
||||||
|
assertEquals(
|
||||||
|
"https://gateway.local/api/files/download?path=/Users/test user/My Documents/holiday photo 2026.png",
|
||||||
|
attachment.uri,
|
||||||
|
)
|
||||||
|
assertEquals("holiday photo 2026.png", attachment.name)
|
||||||
|
assertEquals("image/png", attachment.mimeType)
|
||||||
|
assertEquals(AttachmentSource.GATEWAY, attachment.source)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun userImageAttachments_deduplicatesRepeatPathsKeepingFirst() {
|
||||||
|
val content =
|
||||||
|
"""
|
||||||
|
First mention
|
||||||
|
@image:/images/duplicated.png
|
||||||
|
Second mention
|
||||||
|
@image:/images/duplicated.png
|
||||||
|
Third mention with whitespace padding
|
||||||
|
@image:/images/duplicated.png
|
||||||
|
Different image
|
||||||
|
@image:/images/unique.png
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val attachments = userImageAttachments(content) { path -> "https://gateway.local/file?p=$path" }
|
||||||
|
|
||||||
|
assertEquals(2, attachments.size)
|
||||||
|
assertEquals("https://gateway.local/file?p=/images/duplicated.png", attachments[0].uri)
|
||||||
|
assertEquals("duplicated.png", attachments[0].name)
|
||||||
|
assertEquals("https://gateway.local/file?p=/images/unique.png", attachments[1].uri)
|
||||||
|
assertEquals("unique.png", attachments[1].name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun userImageAttachments_ignoresInlineProseEmptyAndRelativeReferences() {
|
||||||
|
val content =
|
||||||
|
"""
|
||||||
|
Do not match inline prose such as check out @image:/absolute/path/foo.png in middle of text
|
||||||
|
Do not match trailing inline text @image:/path/bar.png extra
|
||||||
|
Do not match relative path @image:relative/path/image.png
|
||||||
|
@image:
|
||||||
|
@image:
|
||||||
|
@image:relative/foo.jpg
|
||||||
|
@image:./local/foo.png
|
||||||
|
@image:../parent/foo.png
|
||||||
|
@image:not-an-absolute-path.jpg
|
||||||
|
Valid absolute path at start of line:
|
||||||
|
@image:/valid/match.png
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val attachments = userImageAttachments(content) { path -> "http://gateway.test$path" }
|
||||||
|
|
||||||
|
assertEquals(1, attachments.size)
|
||||||
|
assertEquals("http://gateway.test/valid/match.png", attachments.single().uri)
|
||||||
|
assertEquals("match.png", attachments.single().name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun userImageAttachments_whenCallbackReturnsNull_returnsNoAttachments() {
|
||||||
|
val content =
|
||||||
|
"""
|
||||||
|
@image:/valid/photo1.jpg
|
||||||
|
@image:/valid/photo2.png
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val attachments = userImageAttachments(content) { null }
|
||||||
|
|
||||||
|
assertTrue(attachments.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun userImageAttachments_whenContentHasNoImageDirectives_returnsEmptyList() {
|
||||||
|
val content = "Just a regular text conversation with no images attached."
|
||||||
|
|
||||||
|
val attachments = userImageAttachments(content) { path -> "https://gw/$path" }
|
||||||
|
|
||||||
|
assertTrue(attachments.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mapServerMessages_withUserTurn_attachesGatewayImageFromPlainStringContent() {
|
||||||
|
val userRow =
|
||||||
|
SessionMessage(
|
||||||
|
id = 1,
|
||||||
|
role = "user",
|
||||||
|
content = JsonPrimitive("Please review this design\n@image:/data/mockups/home.png"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val mapped =
|
||||||
|
mapServerMessages(
|
||||||
|
sessionId = "session-1",
|
||||||
|
messages = listOf(userRow),
|
||||||
|
offset = 0,
|
||||||
|
latestPaging = true,
|
||||||
|
liveMessages = emptyList(),
|
||||||
|
mediaUrl = { path -> "https://gateway.local/files?p=$path" },
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, mapped.size)
|
||||||
|
val chatMessage = mapped.single()
|
||||||
|
assertEquals(MessageRole.USER, chatMessage.role)
|
||||||
|
assertTrue(chatMessage.content.contains("Please review this design"))
|
||||||
|
assertTrue(chatMessage.content.contains("@image:/data/mockups/home.png"))
|
||||||
|
|
||||||
|
val attachments = chatMessage.attachments
|
||||||
|
assertNotNull(attachments)
|
||||||
|
assertEquals(1, attachments!!.size)
|
||||||
|
|
||||||
|
val att = attachments.single()
|
||||||
|
assertEquals("https://gateway.local/files?p=/data/mockups/home.png", att.uri)
|
||||||
|
assertEquals("home.png", att.name)
|
||||||
|
assertEquals("image/png", att.mimeType)
|
||||||
|
assertEquals(AttachmentSource.GATEWAY, att.source)
|
||||||
|
assertTrue(att.isGateway)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mapServerMessages_withUserMultipart748kPayload_preservesCaptionAndNoBase64InContent() {
|
||||||
|
val largeBase64 = "data:image/png;base64," + "iVBORw0KGgoAAAANSUhEUgAA".repeat(31_000) // ~748k chars
|
||||||
|
val caption = "Check out this screenshot\n@image:/opt/data/images/screen.png"
|
||||||
|
|
||||||
|
val multipart =
|
||||||
|
buildJsonArray {
|
||||||
|
add(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", "image_url")
|
||||||
|
putJsonObject("image_url") {
|
||||||
|
put("url", largeBase64)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", "text")
|
||||||
|
put("text", caption)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val userRow =
|
||||||
|
SessionMessage(
|
||||||
|
id = 42,
|
||||||
|
role = "user",
|
||||||
|
content = multipart,
|
||||||
|
)
|
||||||
|
|
||||||
|
val mapped =
|
||||||
|
mapServerMessages(
|
||||||
|
sessionId = "session-1",
|
||||||
|
messages = listOf(userRow),
|
||||||
|
offset = 0,
|
||||||
|
latestPaging = true,
|
||||||
|
liveMessages = emptyList(),
|
||||||
|
mediaUrl = { path -> "https://gw.internal/download?path=$path" },
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, mapped.size)
|
||||||
|
val chatMessage = mapped.single()
|
||||||
|
|
||||||
|
// Content must preserve the caption and reference text, but contain no base64 payload
|
||||||
|
assertTrue(chatMessage.content.contains("Check out this screenshot"))
|
||||||
|
assertTrue(chatMessage.content.contains("@image:/opt/data/images/screen.png"))
|
||||||
|
assertFalse(chatMessage.content.contains("data:image/png;base64"))
|
||||||
|
assertFalse(chatMessage.content.contains("iVBORw0KGgoAAAANSUhEUgAA"))
|
||||||
|
|
||||||
|
// Attachment hydrated from @image directive
|
||||||
|
val attachments = chatMessage.attachments
|
||||||
|
assertNotNull(attachments)
|
||||||
|
assertEquals(1, attachments!!.size)
|
||||||
|
|
||||||
|
val att = attachments.single()
|
||||||
|
assertEquals("https://gw.internal/download?path=/opt/data/images/screen.png", att.uri)
|
||||||
|
assertEquals("screen.png", att.name)
|
||||||
|
assertEquals("image/png", att.mimeType)
|
||||||
|
assertEquals(AttachmentSource.GATEWAY, att.source)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mapServerMessages_withUserTurn_deduplicatesRepeatImagePaths() {
|
||||||
|
val content =
|
||||||
|
"""
|
||||||
|
Examining these photos:
|
||||||
|
@image:/data/photos/target.jpg
|
||||||
|
@image:/data/photos/target.jpg
|
||||||
|
@image:/data/photos/target.jpg
|
||||||
|
@image:/data/photos/another.png
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val userRow =
|
||||||
|
SessionMessage(
|
||||||
|
id = 5,
|
||||||
|
role = "user",
|
||||||
|
content = JsonPrimitive(content),
|
||||||
|
)
|
||||||
|
|
||||||
|
val mapped =
|
||||||
|
mapServerMessages(
|
||||||
|
sessionId = "session-1",
|
||||||
|
messages = listOf(userRow),
|
||||||
|
offset = 0,
|
||||||
|
latestPaging = true,
|
||||||
|
liveMessages = emptyList(),
|
||||||
|
mediaUrl = { path -> "https://gw.internal$path" },
|
||||||
|
)
|
||||||
|
|
||||||
|
val chatMessage = mapped.single()
|
||||||
|
val attachments = chatMessage.attachments
|
||||||
|
assertNotNull(attachments)
|
||||||
|
assertEquals(2, attachments!!.size)
|
||||||
|
assertEquals("https://gw.internal/data/photos/target.jpg", attachments[0].uri)
|
||||||
|
assertEquals("target.jpg", attachments[0].name)
|
||||||
|
assertEquals("https://gw.internal/data/photos/another.png", attachments[1].uri)
|
||||||
|
assertEquals("another.png", attachments[1].name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mapServerMessages_withUserTurn_whenGatewayUnavailable_preservesCaptionAndAddsNoAttachments() {
|
||||||
|
val caption = "Look at this error log\n@image:/var/log/screenshot.png"
|
||||||
|
val userRow =
|
||||||
|
SessionMessage(
|
||||||
|
id = 10,
|
||||||
|
role = "user",
|
||||||
|
content = JsonPrimitive(caption),
|
||||||
|
)
|
||||||
|
|
||||||
|
val mapped =
|
||||||
|
mapServerMessages(
|
||||||
|
sessionId = "session-1",
|
||||||
|
messages = listOf(userRow),
|
||||||
|
offset = 0,
|
||||||
|
latestPaging = true,
|
||||||
|
liveMessages = emptyList(),
|
||||||
|
mediaUrl = { null }, // Gateway unavailable
|
||||||
|
)
|
||||||
|
|
||||||
|
val chatMessage = mapped.single()
|
||||||
|
assertTrue(chatMessage.content.contains("Look at this error log"))
|
||||||
|
assertTrue(chatMessage.content.contains("@image:/var/log/screenshot.png"))
|
||||||
|
assertNull(chatMessage.attachments)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hideImageRefLines_removesOnlyPathLinesAndKeepsCaption() {
|
||||||
|
val content = "caption\n@image:/opt/data/images/a.jpg\nmore text @image:/inline.png"
|
||||||
|
assertEquals("caption\nmore text @image:/inline.png", hideImageRefLines(content))
|
||||||
|
assertEquals("plain", hideImageRefLines("plain"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+342
@@ -0,0 +1,342 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
|
import com.m57.hermescontrol.ui.chat.ChatAttachmentTarget
|
||||||
|
import com.m57.hermescontrol.ui.chat.PastedImage
|
||||||
|
import com.m57.hermescontrol.ui.chat.PastedImageTooLargeException
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.awaitCancellation
|
||||||
|
import kotlinx.coroutines.job
|
||||||
|
import kotlinx.coroutines.joinAll
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.TemporaryFolder
|
||||||
|
import java.io.RandomAccessFile
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class, ExperimentalCoroutinesApi::class)
|
||||||
|
class ChatImagePasteControllerTest {
|
||||||
|
@get:Rule val temp = TemporaryFolder()
|
||||||
|
private val target = ChatAttachmentTarget("session", 1, "http://test.local", null, null)
|
||||||
|
|
||||||
|
private fun image(name: String): PastedImage {
|
||||||
|
val file = temp.newFile(name).apply { writeBytes(byteArrayOf(1, 2)) }
|
||||||
|
return PastedImage(Attachment("content://test/$name", name, "image/png", file.length()), file)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun successPublishesAnOrderedBatchOnlyAfterEveryImageIsCopied() =
|
||||||
|
runTest {
|
||||||
|
val first = image("one.png")
|
||||||
|
val second = image("two.png")
|
||||||
|
val gate = CompletableDeferred<Unit>()
|
||||||
|
val committed = mutableListOf<Attachment>()
|
||||||
|
val errors = mutableListOf<Int>()
|
||||||
|
var count = 0
|
||||||
|
val controller =
|
||||||
|
ChatImagePasteController(
|
||||||
|
this,
|
||||||
|
importImage = { _, _ ->
|
||||||
|
if (count++ == 0) {
|
||||||
|
first
|
||||||
|
} else {
|
||||||
|
gate.await()
|
||||||
|
second
|
||||||
|
}
|
||||||
|
},
|
||||||
|
captureTarget = { target },
|
||||||
|
commit = { _, attachments ->
|
||||||
|
committed.addAll(attachments)
|
||||||
|
true
|
||||||
|
},
|
||||||
|
onError = errors::add,
|
||||||
|
)
|
||||||
|
assertTrue(controller.receiveImages(listOf(mockk<Uri>(), mockk<Uri>()), Any()))
|
||||||
|
assertTrue(controller.isReceiving)
|
||||||
|
runCurrent()
|
||||||
|
assertTrue(committed.isEmpty())
|
||||||
|
gate.complete(Unit)
|
||||||
|
coroutineContext.job.children
|
||||||
|
.toList()
|
||||||
|
.joinAll()
|
||||||
|
assertFalse(controller.isReceiving)
|
||||||
|
assertEquals(listOf(first.attachment, second.attachment), committed)
|
||||||
|
assertTrue(first.file.exists() && second.file.exists())
|
||||||
|
assertTrue(errors.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun laterUnreadableImageRollsBackTheEntireBatch() =
|
||||||
|
runTest {
|
||||||
|
val first = image("rollback.png")
|
||||||
|
var count = 0
|
||||||
|
var commits = 0
|
||||||
|
val errors = mutableListOf<Int>()
|
||||||
|
val controller =
|
||||||
|
ChatImagePasteController(
|
||||||
|
this,
|
||||||
|
importImage = { _, _ -> if (count++ == 0) first else null },
|
||||||
|
captureTarget = { target },
|
||||||
|
commit = { _, _ ->
|
||||||
|
commits++
|
||||||
|
true
|
||||||
|
},
|
||||||
|
onError = errors::add,
|
||||||
|
)
|
||||||
|
controller.receiveImages(listOf(mockk<Uri>(), mockk<Uri>()), Any())
|
||||||
|
coroutineContext.job.children
|
||||||
|
.toList()
|
||||||
|
.joinAll()
|
||||||
|
assertEquals(0, commits)
|
||||||
|
assertFalse(first.file.exists())
|
||||||
|
assertEquals(listOf(R.string.chat_image_paste_failed), errors)
|
||||||
|
assertFalse(controller.isReceiving)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sessionChangeDuringCopyDiscardsImageWithoutTouchingTheNewChat() =
|
||||||
|
runTest {
|
||||||
|
val result = image("stale.png")
|
||||||
|
val gate = CompletableDeferred<Unit>()
|
||||||
|
var owner = target
|
||||||
|
var commits = 0
|
||||||
|
val errors = mutableListOf<Int>()
|
||||||
|
val controller =
|
||||||
|
ChatImagePasteController(
|
||||||
|
this,
|
||||||
|
importImage = { _, _ ->
|
||||||
|
gate.await()
|
||||||
|
result
|
||||||
|
},
|
||||||
|
captureTarget = { owner },
|
||||||
|
commit = { _, _ ->
|
||||||
|
commits++
|
||||||
|
true
|
||||||
|
},
|
||||||
|
onError = errors::add,
|
||||||
|
)
|
||||||
|
controller.receiveImages(listOf(mockk<Uri>()), Any())
|
||||||
|
runCurrent()
|
||||||
|
owner = target.copy(generation = 3)
|
||||||
|
gate.complete(Unit)
|
||||||
|
coroutineContext.job.children
|
||||||
|
.toList()
|
||||||
|
.joinAll()
|
||||||
|
assertEquals(0, commits)
|
||||||
|
assertFalse(result.file.exists())
|
||||||
|
assertTrue(errors.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun disposalCancelsCopyAndDeletesOnlyUncommittedFiles() =
|
||||||
|
runTest {
|
||||||
|
val first = image("cancel.png")
|
||||||
|
var count = 0
|
||||||
|
var commits = 0
|
||||||
|
val errors = mutableListOf<Int>()
|
||||||
|
val controller =
|
||||||
|
ChatImagePasteController(
|
||||||
|
this,
|
||||||
|
importImage = { _, _ -> if (count++ == 0) first else awaitCancellation() },
|
||||||
|
captureTarget = { target },
|
||||||
|
commit = { _, _ ->
|
||||||
|
commits++
|
||||||
|
true
|
||||||
|
},
|
||||||
|
onError = errors::add,
|
||||||
|
)
|
||||||
|
controller.receiveImages(listOf(mockk<Uri>(), mockk<Uri>()), Any())
|
||||||
|
runCurrent()
|
||||||
|
controller.close()
|
||||||
|
coroutineContext.job.children
|
||||||
|
.toList()
|
||||||
|
.joinAll()
|
||||||
|
assertFalse(first.file.exists())
|
||||||
|
assertEquals(0, commits)
|
||||||
|
assertTrue(errors.isEmpty())
|
||||||
|
assertFalse(controller.receiveImages(listOf(mockk<Uri>()), Any()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unreadySessionNeverOpensTheProvider() =
|
||||||
|
runTest {
|
||||||
|
var reads = 0
|
||||||
|
val errors = mutableListOf<Int>()
|
||||||
|
val controller =
|
||||||
|
ChatImagePasteController(
|
||||||
|
this,
|
||||||
|
importImage = { _, _ ->
|
||||||
|
reads++
|
||||||
|
null
|
||||||
|
},
|
||||||
|
captureTarget = { null },
|
||||||
|
commit = { _, _ -> error("Must not commit") },
|
||||||
|
onError = errors::add,
|
||||||
|
)
|
||||||
|
assertTrue(controller.receiveImages(listOf(mockk<Uri>()), Any()))
|
||||||
|
assertEquals(0, reads)
|
||||||
|
assertEquals(listOf(R.string.chat_session_not_ready), errors)
|
||||||
|
assertFalse(controller.isReceiving)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun secondPasteWhileImportingDoesNotStartAnotherBatch() =
|
||||||
|
runTest {
|
||||||
|
val result = image("busy.png")
|
||||||
|
val gate = CompletableDeferred<Unit>()
|
||||||
|
var reads = 0
|
||||||
|
val errors = mutableListOf<Int>()
|
||||||
|
val controller =
|
||||||
|
ChatImagePasteController(
|
||||||
|
this,
|
||||||
|
importImage = { _, _ ->
|
||||||
|
reads++
|
||||||
|
gate.await()
|
||||||
|
result
|
||||||
|
},
|
||||||
|
captureTarget = { target },
|
||||||
|
commit = { _, _ -> true },
|
||||||
|
onError = errors::add,
|
||||||
|
)
|
||||||
|
controller.receiveImages(listOf(mockk<Uri>()), Any())
|
||||||
|
controller.receiveImages(listOf(mockk<Uri>()), Any())
|
||||||
|
gate.complete(Unit)
|
||||||
|
coroutineContext.job.children
|
||||||
|
.toList()
|
||||||
|
.joinAll()
|
||||||
|
assertEquals(1, reads)
|
||||||
|
assertEquals(listOf(R.string.chat_image_paste_busy), errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ninthImageRejectsBeforeOpeningAnyProvider() =
|
||||||
|
runTest {
|
||||||
|
var reads = 0
|
||||||
|
val errors = mutableListOf<Int>()
|
||||||
|
val controller =
|
||||||
|
ChatImagePasteController(
|
||||||
|
this,
|
||||||
|
importImage = { _, _ ->
|
||||||
|
reads++
|
||||||
|
null
|
||||||
|
},
|
||||||
|
captureTarget = { target },
|
||||||
|
commit = { _, _ -> error("Must not commit") },
|
||||||
|
onError = errors::add,
|
||||||
|
)
|
||||||
|
assertTrue(controller.receiveImages(List(9) { mockk<Uri>() }, Any()))
|
||||||
|
runCurrent()
|
||||||
|
assertEquals(0, reads)
|
||||||
|
assertEquals(listOf(R.string.chat_image_paste_batch_limit), errors)
|
||||||
|
assertFalse(controller.isReceiving)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun exactBatchLimitKeepsQueuedFilesAndPassesRemainingBudget() =
|
||||||
|
runTest {
|
||||||
|
val first = image("first-large.png")
|
||||||
|
val second = image("second-large.png")
|
||||||
|
listOf(first, second).forEach {
|
||||||
|
RandomAccessFile(it.file, "rw").use { file ->
|
||||||
|
file.setLength(10L * 1024 * 1024)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val caps = mutableListOf<Long>()
|
||||||
|
val committed = mutableListOf<Attachment>()
|
||||||
|
val controller =
|
||||||
|
ChatImagePasteController(
|
||||||
|
this,
|
||||||
|
importImage = { _, cap ->
|
||||||
|
caps += cap
|
||||||
|
if (caps.size == 1) first else second
|
||||||
|
},
|
||||||
|
captureTarget = { target },
|
||||||
|
commit = { _, attachments ->
|
||||||
|
committed.addAll(attachments)
|
||||||
|
true
|
||||||
|
},
|
||||||
|
onError = { error("Unexpected error: $it") },
|
||||||
|
)
|
||||||
|
controller.receiveImages(List(2) { mockk<Uri>() }, Any())
|
||||||
|
runCurrent()
|
||||||
|
assertEquals(listOf(10L * 1024 * 1024, 10L * 1024 * 1024), caps)
|
||||||
|
assertEquals(listOf(first.attachment, second.attachment), committed)
|
||||||
|
assertTrue(first.file.exists() && second.file.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun exhaustedBudgetRejectsNextWithoutIoAndRollsBack() =
|
||||||
|
runTest {
|
||||||
|
val first = image("full-a.png")
|
||||||
|
val second = image("full-b.png")
|
||||||
|
listOf(first, second).forEach {
|
||||||
|
RandomAccessFile(it.file, "rw").use { file ->
|
||||||
|
file.setLength(10L * 1024 * 1024)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var reads = 0
|
||||||
|
var commits = 0
|
||||||
|
val errors = mutableListOf<Int>()
|
||||||
|
val controller =
|
||||||
|
ChatImagePasteController(
|
||||||
|
this,
|
||||||
|
importImage = { _, _ -> if (++reads == 1) first else second },
|
||||||
|
captureTarget = { target },
|
||||||
|
commit = { _, _ ->
|
||||||
|
commits++
|
||||||
|
true
|
||||||
|
},
|
||||||
|
onError = errors::add,
|
||||||
|
)
|
||||||
|
controller.receiveImages(List(3) { mockk<Uri>() }, Any())
|
||||||
|
coroutineContext.job.children
|
||||||
|
.toList()
|
||||||
|
.joinAll()
|
||||||
|
assertEquals(2, reads)
|
||||||
|
assertEquals(0, commits)
|
||||||
|
assertFalse(first.file.exists() || second.file.exists())
|
||||||
|
assertEquals(listOf(R.string.chat_image_paste_batch_limit), errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun remainingBudgetOverflowReportsBatchLimitAndCleansUp() =
|
||||||
|
runTest {
|
||||||
|
val first = image("partial-a.png")
|
||||||
|
val second = image("partial-b.png")
|
||||||
|
RandomAccessFile(first.file, "rw").use { it.setLength(10L * 1024 * 1024) }
|
||||||
|
RandomAccessFile(second.file, "rw").use { it.setLength(2L * 1024 * 1024) }
|
||||||
|
val caps = mutableListOf<Long>()
|
||||||
|
val errors = mutableListOf<Int>()
|
||||||
|
val controller =
|
||||||
|
ChatImagePasteController(
|
||||||
|
this,
|
||||||
|
importImage = { _, cap ->
|
||||||
|
caps += cap
|
||||||
|
when (caps.size) {
|
||||||
|
1 -> first
|
||||||
|
2 -> second
|
||||||
|
else -> throw PastedImageTooLargeException()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
captureTarget = { target },
|
||||||
|
commit = { _, _ -> error("Must not commit") },
|
||||||
|
onError = errors::add,
|
||||||
|
)
|
||||||
|
controller.receiveImages(List(3) { mockk<Uri>() }, Any())
|
||||||
|
coroutineContext.job.children
|
||||||
|
.toList()
|
||||||
|
.joinAll()
|
||||||
|
assertEquals(listOf(10L * 1024 * 1024, 10L * 1024 * 1024, 8L * 1024 * 1024), caps)
|
||||||
|
assertFalse(first.file.exists() || second.file.exists())
|
||||||
|
assertEquals(listOf(R.string.chat_image_paste_batch_limit), errors)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@ import com.m57.hermescontrol.ui.chat.MessageRole
|
|||||||
import com.m57.hermescontrol.ui.chat.PendingSend
|
import com.m57.hermescontrol.ui.chat.PendingSend
|
||||||
import com.m57.hermescontrol.ui.chat.PendingSendState
|
import com.m57.hermescontrol.ui.chat.PendingSendState
|
||||||
import com.m57.hermescontrol.ui.chat.StreamingState
|
import com.m57.hermescontrol.ui.chat.StreamingState
|
||||||
|
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
|
||||||
|
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
|
||||||
|
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
@@ -22,6 +25,54 @@ class TranscriptUiStateTest {
|
|||||||
private val historical = ChatMessage(id = "historical", role = MessageRole.USER, content = "older")
|
private val historical = ChatMessage(id = "historical", role = MessageRole.USER, content = "older")
|
||||||
private val clarify = ClarifyUi(text = "choose")
|
private val clarify = ClarifyUi(text = "choose")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptyStateDoesNotHideOtherRenderableTailContent() {
|
||||||
|
val empty = TranscriptUiState.resolve(ChatUiState(), ChatTimelineState(), StreamingState(), null, null)
|
||||||
|
assertTrue(shouldShowChatEmptyState(empty, hasReplyError = false))
|
||||||
|
val visibleStates =
|
||||||
|
listOf(
|
||||||
|
empty.copy(vaultUnlockPrompt = VaultUnlockPromptUi("unlock", "session")),
|
||||||
|
empty.copy(vaultSaveLoginPrompt = VaultSaveLoginPromptUi("save", "session")),
|
||||||
|
empty.copy(vaultCodePrompt = VaultCodePromptUi("code", "session")),
|
||||||
|
empty.copy(isCompressing = true),
|
||||||
|
empty.copy(compressionStatus = "Compressing"),
|
||||||
|
empty.copy(streamingState = StreamingState(streamingMessage = live)),
|
||||||
|
empty.copy(isAgentTyping = true),
|
||||||
|
empty.copy(isLoading = true),
|
||||||
|
empty.copy(messages = listOf(live)),
|
||||||
|
)
|
||||||
|
visibleStates.forEach { assertFalse(shouldShowChatEmptyState(it, hasReplyError = false)) }
|
||||||
|
assertFalse(shouldShowChatEmptyState(empty, hasReplyError = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clarify on empty live transcript uses list rather than empty state`() {
|
||||||
|
val transcript =
|
||||||
|
TranscriptUiState.resolve(
|
||||||
|
ChatUiState(currentSessionId = "session", clarifyRequest = clarify),
|
||||||
|
ChatTimelineState(),
|
||||||
|
StreamingState(),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
assertSame(clarify, transcript.clarifyRequest)
|
||||||
|
assertFalse(shouldShowChatEmptyState(transcript, hasReplyError = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty historical snapshot remains empty without displaying live clarify`() {
|
||||||
|
val transcript =
|
||||||
|
TranscriptUiState.resolve(
|
||||||
|
ChatUiState(currentSessionId = "session", clarifyRequest = clarify),
|
||||||
|
ChatTimelineState(historyMessages = emptyList()),
|
||||||
|
StreamingState(),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
assertNull(transcript.clarifyRequest)
|
||||||
|
assertTrue(shouldShowChatEmptyState(transcript, hasReplyError = false))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun acceptedLivePromptKeepsItsTextAndImageUntilHistoryConfirmsIt() {
|
fun acceptedLivePromptKeepsItsTextAndImageUntilHistoryConfirmsIt() {
|
||||||
val image = Attachment("file:///private/photo", "photo.png", "image/png", 3)
|
val image = Attachment("file:///private/photo", "photo.png", "image/png", 3)
|
||||||
|
|||||||
Reference in new issue
Block a user