Co-authored-by: M57 <41645758+Hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
0e228dec18
commit
ae9003decd
19 files changed
+1619
-89
No files matched your search
@@ -2,8 +2,10 @@ package com.m57.hermescontrol.data.remote
|
||||
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
@@ -59,6 +61,7 @@ class ProfileScopeInterceptorTest {
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody("{}"))
|
||||
val builder = Request.Builder().url(server.url(path))
|
||||
when (method) {
|
||||
"POST" -> builder.post("{}".toRequestBody("application/json".toMediaType()))
|
||||
"DELETE" -> builder.delete()
|
||||
else -> builder.get()
|
||||
}
|
||||
@@ -269,4 +272,27 @@ class ProfileScopeInterceptorTest {
|
||||
assertEquals("work", url.queryParameter("profile"))
|
||||
assertEquals("/api/status/health", url.encodedPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcribeRoute_getsTheActiveNonDefaultProfile() {
|
||||
// Review (PR #1250): /api/audio/transcribe resolves STT through the
|
||||
// request's profile on the backend — the desktop sends this route
|
||||
// `...profileScoped()`. Unscoped, a multi-profile host would
|
||||
// transcribe under the launch profile instead of the user's pick.
|
||||
val client = clientFor("work")
|
||||
|
||||
val url = requestedUrl(client, "api/audio/transcribe", method = "POST")
|
||||
|
||||
assertEquals("work", url.queryParameter("profile"))
|
||||
assertEquals("/api/audio/transcribe", url.encodedPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lookalikeAudioPath_notScoped() {
|
||||
val client = clientFor("work")
|
||||
|
||||
val url = requestedUrl(client, "api/audio-extras")
|
||||
|
||||
assertNull(url.queryParameter("profile"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.m57.hermescontrol.data.repository
|
||||
|
||||
import com.m57.hermescontrol.data.model.AudioTranscriptionRequest
|
||||
import com.m57.hermescontrol.data.model.AudioTranscriptionResponse
|
||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import retrofit2.Response
|
||||
import java.io.File
|
||||
import java.net.SocketTimeoutException
|
||||
|
||||
class VoiceNoteRepositoryTest {
|
||||
private val api = mockk<HermesApiService>()
|
||||
|
||||
private fun tempVoiceFile(bytes: ByteArray = byteArrayOf(1)): File =
|
||||
File.createTempFile("voice-note-test", ".m4a").apply {
|
||||
writeBytes(bytes)
|
||||
deleteOnExit()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcribe_uploadsBase64DataUrlAndReturnsTrimmedTranscript() =
|
||||
runTest {
|
||||
val file = tempVoiceFile(byteArrayOf(1, 2, 3))
|
||||
val bodySlot = slot<AudioTranscriptionRequest>()
|
||||
coEvery { api.transcribeAudio(capture(bodySlot)) } returns
|
||||
Response.success(
|
||||
AudioTranscriptionResponse(ok = true, transcript = " hello world "),
|
||||
)
|
||||
|
||||
val result = VoiceNoteRepository { _ -> api }.transcribe(file)
|
||||
|
||||
val success = result as NetworkResult.Success
|
||||
assertEquals("hello world", success.data)
|
||||
assertEquals("audio/mp4", bodySlot.captured.mimeType)
|
||||
assertEquals("data:audio/mp4;base64,AQID", bodySlot.captured.dataUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcribe_propagatesServerFailureWithoutRetrying() =
|
||||
runTest {
|
||||
val file = tempVoiceFile()
|
||||
coEvery { api.transcribeAudio(any()) } returns
|
||||
Response.error(422, "no speech".toResponseBody("text/plain".toMediaType()))
|
||||
|
||||
val result = VoiceNoteRepository { _ -> api }.transcribe(file)
|
||||
|
||||
assertTrue(result is NetworkResult.Failure)
|
||||
coVerify(exactly = 1) { api.transcribeAudio(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcribe_doesNotRetryThePostAfterASocketTimeout() =
|
||||
runTest {
|
||||
// safeApiCall's default policy retries socket timeouts and 5xx; a
|
||||
// blind re-POST would repeat provider STT work after the request
|
||||
// may already have reached the server (review, PR #1250).
|
||||
val file = tempVoiceFile()
|
||||
coEvery { api.transcribeAudio(any()) } throws SocketTimeoutException("stt timeout")
|
||||
|
||||
val result = VoiceNoteRepository { _ -> api }.transcribe(file)
|
||||
|
||||
assertTrue(result is NetworkResult.Failure)
|
||||
coVerify(exactly = 1) { api.transcribeAudio(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcribe_resolvesTheServiceAtCallTime_soAConnectionSwitchUsesTheNewService() =
|
||||
runTest {
|
||||
val firstApi = mockk<HermesApiService>()
|
||||
val secondApi = mockk<HermesApiService>()
|
||||
coEvery { firstApi.transcribeAudio(any()) } returns
|
||||
Response.success(AudioTranscriptionResponse(ok = true, transcript = "old server"))
|
||||
coEvery { secondApi.transcribeAudio(any()) } returns
|
||||
Response.success(AudioTranscriptionResponse(ok = true, transcript = "new server"))
|
||||
var currentApi = firstApi
|
||||
val repository = VoiceNoteRepository { _ -> currentApi }
|
||||
|
||||
val before = repository.transcribe(tempVoiceFile())
|
||||
// Simulate ApiClient.rebuild() after a connection/profile switch:
|
||||
// the next call must use the new service, not the captured one.
|
||||
currentApi = secondApi
|
||||
val after = repository.transcribe(tempVoiceFile())
|
||||
|
||||
assertEquals("old server", (before as NetworkResult.Success).data)
|
||||
assertEquals("new server", (after as NetworkResult.Success).data)
|
||||
coVerify(exactly = 1) { firstApi.transcribeAudio(any()) }
|
||||
coVerify(exactly = 1) { secondApi.transcribeAudio(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcribe_requestsTheDesktopParitySttTimeout() =
|
||||
runTest {
|
||||
val requestedTimeouts = mutableListOf<Long>()
|
||||
coEvery { api.transcribeAudio(any()) } returns
|
||||
Response.success(AudioTranscriptionResponse(ok = true, transcript = "ok"))
|
||||
|
||||
VoiceNoteRepository { timeout ->
|
||||
requestedTimeouts += timeout
|
||||
api
|
||||
}.transcribe(tempVoiceFile())
|
||||
|
||||
// A tiny clip must still get the 180s floor, not the shared 30s client.
|
||||
assertEquals(listOf(VoiceNoteRepository.MIN_REQUEST_TIMEOUT_MS), requestedTimeouts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcriptionTimeoutMs_scalesWithPayloadThenClamps() {
|
||||
assertEquals(180_000L, VoiceNoteRepository.transcriptionTimeoutMs("data:audio/mp4;base64,AQID"))
|
||||
assertEquals(200_000L, VoiceNoteRepository.transcriptionTimeoutMs("x".repeat(2_000_000)))
|
||||
assertEquals(600_000L, VoiceNoteRepository.transcriptionTimeoutMs("x".repeat(10_000_000)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcribe_unreadableFileReturnsFailureWithoutUploading() =
|
||||
runTest {
|
||||
val missing = tempVoiceFile().apply { delete() }
|
||||
|
||||
val result = VoiceNoteRepository { _ -> api }.transcribe(missing)
|
||||
|
||||
assertTrue(result is NetworkResult.Failure)
|
||||
coVerify(exactly = 0) { api.transcribeAudio(any()) }
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.app.Application
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.HermesDatabase
|
||||
import com.m57.hermescontrol.data.model.ActionResponse
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
@@ -76,6 +77,14 @@ class ChatUpdateCommandTest {
|
||||
every { android.util.Log.e(any(), any(), any()) } returns 0
|
||||
|
||||
mockkObject(AuthManager)
|
||||
// ChatModelSwitchDelegate.preloadModelOptions() reads pinned models
|
||||
// when the catalog load succeeds; without this stub the spy falls
|
||||
// through to the real AuthManager and throws "not initialized".
|
||||
every { AuthManager.getPinnedModels() } returns emptyList()
|
||||
// The delegate and the shared catalog store both collect this flow;
|
||||
// park them on a never-emitting state so a later real-AuthManager
|
||||
// emission cannot resume a stale Main-dispatched collector.
|
||||
every { AuthManager.dataScopeFlow } returns MutableStateFlow<DataScope?>(null)
|
||||
mockkObject(HermesWsClient)
|
||||
mockkObject(ApiClient)
|
||||
mockkObject(HermesDatabase)
|
||||
|
||||
@@ -8,9 +8,11 @@ import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.HermesDatabase
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||
import com.m57.hermescontrol.data.model.AudioTranscriptionResponse
|
||||
import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import com.m57.hermescontrol.data.model.PaginationInfo
|
||||
import com.m57.hermescontrol.data.model.SessionMessage
|
||||
@@ -30,6 +32,7 @@ import com.m57.hermescontrol.data.ws.ConnectionOperationParser
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.JsonRpcError
|
||||
import com.m57.hermescontrol.data.ws.ModelCatalogStore
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import com.m57.hermescontrol.notification.TurnCorrelationTracker
|
||||
@@ -65,6 +68,7 @@ import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
@@ -75,6 +79,7 @@ import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import retrofit2.Response
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ChatViewModelTest {
|
||||
@@ -298,6 +303,18 @@ class ChatViewModelTest {
|
||||
every { AuthManager.getToken() } returns "test-token"
|
||||
every { AuthManager.getBaseUrl() } returns "http://test.local/"
|
||||
every { AuthManager.getSelectedProfileId() } returns null
|
||||
// sendVoiceNote snapshots the data scope for its ownership check
|
||||
// (review, PR #1250). Default it to null: an unstubbed mockk call
|
||||
// used to throw and be swallowed by the runCatching callers, which
|
||||
// kept SwrCache keys unscoped in this class; returning a concrete
|
||||
// scope here enables cross-test cache hits and skips sends other
|
||||
// tests count. Scope-specific tests re-stub this with real scopes.
|
||||
every { AuthManager.currentDataScope() } returns null
|
||||
// ChatViewModel's model-switch delegate and the shared catalog store
|
||||
// both collect this flow; park them on a never-emitting state so a
|
||||
// later real-AuthManager emission cannot resume a stale
|
||||
// Main-dispatched collector between test classes.
|
||||
every { AuthManager.dataScopeFlow } returns MutableStateFlow<DataScope?>(null)
|
||||
mockkObject(ProfileSwitchCoordinator)
|
||||
every { ProfileSwitchCoordinator.switched } returns mockSwitchFlow
|
||||
every { ProfileSwitchCoordinator.connectionSwitched } returns MutableSharedFlow<String>()
|
||||
@@ -1098,6 +1115,13 @@ class ChatViewModelTest {
|
||||
runTest {
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
|
||||
// The shared catalog store may still hold another class's cached
|
||||
// (possibly empty) state — reset it so the picker's load below
|
||||
// fetches through this class's stubbed API.
|
||||
ModelCatalogStore.shared.onScopeChanged(
|
||||
DataScope("preload-reset", "http://preload-reset.test/", AuthManager.DEFAULT_PROFILE_ID),
|
||||
)
|
||||
|
||||
// A bare "/model" must NOT dispatch a slash command; it opens the picker.
|
||||
viewModel.sendMessage("/model")
|
||||
advanceUntilIdle()
|
||||
@@ -9746,4 +9770,414 @@ class ChatViewModelTest {
|
||||
}
|
||||
assertNull(TurnCorrelationTracker.boundaryFor("default", sessionId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendVoiceNote_transcribesViaServerAndSubmitsTranscript() =
|
||||
runTest {
|
||||
TurnCorrelationTracker.resetForTest()
|
||||
stubActiveProfile()
|
||||
val api = mockk<com.m57.hermescontrol.data.remote.HermesApiService>(relaxed = true)
|
||||
every { ApiClient.hermesApi } returns api
|
||||
every { ApiClient.transcriptionService(any()) } returns api
|
||||
coEvery { api.transcribeAudio(any()) } returns
|
||||
Response.success(
|
||||
AudioTranscriptionResponse(ok = true, transcript = "hello from voice"),
|
||||
)
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
|
||||
val submittedTexts = mutableListOf<String>()
|
||||
every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers {
|
||||
reqCount++
|
||||
val id = "req-msg-$reqCount"
|
||||
submittedTexts += arg<String>(1)
|
||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||
id
|
||||
}
|
||||
|
||||
val voiceFile =
|
||||
java.io.File(attachmentCacheDir, "voice_note.m4a").apply {
|
||||
writeBytes(byteArrayOf(1, 2, 3))
|
||||
}
|
||||
viewModel.sendVoiceNote(voiceFile)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("hello from voice", submittedTexts.lastOrNull())
|
||||
assertFalse(voiceFile.exists())
|
||||
assertNull(viewModel.uiState.value.errorMessage)
|
||||
assertFalse(viewModel.uiState.value.isTranscribingVoiceNote)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendVoiceNote_reportsTranscriptionFailureAndDeletesClip() =
|
||||
runTest {
|
||||
val api = mockk<com.m57.hermescontrol.data.remote.HermesApiService>(relaxed = true)
|
||||
every { ApiClient.hermesApi } returns api
|
||||
every { ApiClient.transcriptionService(any()) } returns api
|
||||
coEvery { api.transcribeAudio(any()) } returns
|
||||
Response.error(500, "transcribe failed".toResponseBody("text/plain".toMediaType()))
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
|
||||
val voiceFile =
|
||||
java.io.File(attachmentCacheDir, "voice_fail.m4a").apply {
|
||||
writeBytes(byteArrayOf(9))
|
||||
}
|
||||
viewModel.sendVoiceNote(voiceFile)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(
|
||||
viewModel.uiState.value.errorMessage
|
||||
?.contains("Voice note transcription failed") == true,
|
||||
)
|
||||
assertFalse(voiceFile.exists())
|
||||
assertFalse(viewModel.uiState.value.isTranscribingVoiceNote)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendVoiceNote_emptyTranscriptShowsNoSpeechAndDoesNotSubmit() =
|
||||
runTest {
|
||||
val api = mockk<com.m57.hermescontrol.data.remote.HermesApiService>(relaxed = true)
|
||||
every { ApiClient.hermesApi } returns api
|
||||
every { ApiClient.transcriptionService(any()) } returns api
|
||||
coEvery { api.transcribeAudio(any()) } returns
|
||||
Response.success(AudioTranscriptionResponse(ok = true, transcript = " "))
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
|
||||
val voiceFile =
|
||||
java.io.File(attachmentCacheDir, "voice_silent.m4a").apply {
|
||||
writeBytes(byteArrayOf(0))
|
||||
}
|
||||
viewModel.sendVoiceNote(voiceFile)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(
|
||||
viewModel.uiState.value.errorMessage
|
||||
?.contains("No speech detected") == true,
|
||||
)
|
||||
verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendVoiceNote_whileDisconnectedDropsClipLocallyWithoutUploading() =
|
||||
runTest {
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
val voiceFile =
|
||||
java.io.File(attachmentCacheDir, "voice_offline.m4a").apply {
|
||||
writeBytes(byteArrayOf(1))
|
||||
}
|
||||
viewModel.sendVoiceNote(voiceFile)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertFalse(voiceFile.exists())
|
||||
assertTrue(
|
||||
viewModel.uiState.value.errorMessage
|
||||
?.contains("not connected") == true,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendVoiceNote_whenSessionChangesMidTranscription_keepsTheTranscriptInTheComposer() =
|
||||
runTest {
|
||||
stubActiveProfile()
|
||||
val api = mockk<com.m57.hermescontrol.data.remote.HermesApiService>(relaxed = true)
|
||||
every { ApiClient.hermesApi } returns api
|
||||
every { ApiClient.transcriptionService(any()) } returns api
|
||||
val transcription = CompletableDeferred<Response<AudioTranscriptionResponse>>()
|
||||
coEvery { api.transcribeAudio(any()) } coAnswers { transcription.await() }
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
|
||||
val voiceFile =
|
||||
java.io.File(attachmentCacheDir, "voice_session_race.m4a").apply {
|
||||
writeBytes(byteArrayOf(7))
|
||||
}
|
||||
viewModel.sendVoiceNote(voiceFile)
|
||||
advanceUntilIdle()
|
||||
|
||||
// The user moves to a new session while STT is still running.
|
||||
viewModel.createNewSession()
|
||||
advanceUntilIdle()
|
||||
|
||||
transcription.complete(
|
||||
Response.success(AudioTranscriptionResponse(ok = true, transcript = "late transcript")),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
// The recording belonged to the old session: the transcript must
|
||||
// survive in the composer instead of landing in a session it was
|
||||
// never recorded for, or vanishing (review, PR #1250).
|
||||
assertEquals("late transcript", viewModel.uiState.value.composerTextToRestore)
|
||||
assertTrue(
|
||||
viewModel.uiState.value.errorMessage
|
||||
?.contains("transcript kept") == true,
|
||||
)
|
||||
verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) }
|
||||
assertFalse(viewModel.uiState.value.isTranscribingVoiceNote)
|
||||
assertFalse(voiceFile.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendVoiceNote_whenTheSendIsRejected_keepsTheTranscriptInTheComposer() =
|
||||
runTest {
|
||||
stubActiveProfile()
|
||||
val api = mockk<com.m57.hermescontrol.data.remote.HermesApiService>(relaxed = true)
|
||||
every { ApiClient.hermesApi } returns api
|
||||
every { ApiClient.transcriptionService(any()) } returns api
|
||||
coEvery { api.transcribeAudio(any()) } returns
|
||||
Response.success(AudioTranscriptionResponse(ok = true, transcript = "keep me"))
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
|
||||
val voiceFile =
|
||||
java.io.File(attachmentCacheDir, "voice_send_race.m4a").apply {
|
||||
writeBytes(byteArrayOf(8))
|
||||
}
|
||||
viewModel.sendVoiceNote(voiceFile)
|
||||
// The connection drops while the transcript is being produced.
|
||||
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("keep me", viewModel.uiState.value.composerTextToRestore)
|
||||
assertTrue(
|
||||
viewModel.uiState.value.errorMessage
|
||||
?.contains("transcript kept") == true,
|
||||
)
|
||||
verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendVoiceNote_whileATranscriptionIsInFlight_dropsTheSecondClip() =
|
||||
runTest {
|
||||
stubActiveProfile()
|
||||
val api = mockk<com.m57.hermescontrol.data.remote.HermesApiService>(relaxed = true)
|
||||
every { ApiClient.hermesApi } returns api
|
||||
every { ApiClient.transcriptionService(any()) } returns api
|
||||
val transcription = CompletableDeferred<Response<AudioTranscriptionResponse>>()
|
||||
coEvery { api.transcribeAudio(any()) } coAnswers { transcription.await() }
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
|
||||
val submittedTexts = mutableListOf<String>()
|
||||
every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers {
|
||||
reqCount++
|
||||
val id = "req-msg-$reqCount"
|
||||
submittedTexts += arg<String>(1)
|
||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||
id
|
||||
}
|
||||
|
||||
val firstFile =
|
||||
java.io.File(attachmentCacheDir, "voice_first.m4a").apply { writeBytes(byteArrayOf(1)) }
|
||||
val secondFile =
|
||||
java.io.File(attachmentCacheDir, "voice_second.m4a").apply { writeBytes(byteArrayOf(2)) }
|
||||
viewModel.sendVoiceNote(firstFile)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.sendVoiceNote(secondFile)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Single-flight: the second clip is dropped without a second upload.
|
||||
assertFalse(secondFile.exists())
|
||||
coVerify(exactly = 1) { api.transcribeAudio(any()) }
|
||||
|
||||
transcription.complete(
|
||||
Response.success(AudioTranscriptionResponse(ok = true, transcript = "only once")),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("only once"), submittedTexts)
|
||||
assertFalse(viewModel.uiState.value.isTranscribingVoiceNote)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendVoiceNote_whenDataScopeChangesDuringTranscription_doesNotSendIntoNewScope() =
|
||||
runTest {
|
||||
stubActiveProfile()
|
||||
val api = mockk<com.m57.hermescontrol.data.remote.HermesApiService>(relaxed = true)
|
||||
every { ApiClient.hermesApi } returns api
|
||||
every { ApiClient.transcriptionService(any()) } returns api
|
||||
val transcription = CompletableDeferred<Response<AudioTranscriptionResponse>>()
|
||||
coEvery { api.transcribeAudio(any()) } coAnswers { transcription.await() }
|
||||
|
||||
every { AuthManager.currentDataScope() } returns
|
||||
DataScope(
|
||||
connectionProfileId = "alpha",
|
||||
baseUrl = "http://alpha.test/",
|
||||
activeProfileId = AuthManager.DEFAULT_PROFILE_ID,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
mockConnectionStatus.value = ConnectionStatus.CONNECTED
|
||||
// A pending SESSION_CREATE is all this test needs; skipping the
|
||||
// gateway preload keeps the shared ModelCatalogStore out of it.
|
||||
viewModel.createNewSession()
|
||||
advanceUntilIdle()
|
||||
|
||||
// SESSION_CREATE is still pending, so the storage session ID is
|
||||
// null — exactly the window where the session-ID snapshot alone
|
||||
// cannot detect a scope move.
|
||||
assertNull(viewModel.uiState.value.currentSessionId)
|
||||
|
||||
val voiceFile =
|
||||
java.io.File(attachmentCacheDir, "voice_scope_switch.m4a").apply {
|
||||
writeBytes(byteArrayOf(1, 2, 3))
|
||||
}
|
||||
viewModel.sendVoiceNote(voiceFile)
|
||||
advanceUntilIdle()
|
||||
assertTrue(viewModel.uiState.value.isTranscribingVoiceNote)
|
||||
|
||||
// The user switches to another server/profile while STT runs; the
|
||||
// replacement session create is pending as well, so both session
|
||||
// IDs can stay null/null and only the data scope moves.
|
||||
every { AuthManager.currentDataScope() } returns
|
||||
DataScope(
|
||||
connectionProfileId = "beta",
|
||||
baseUrl = "http://beta.test/",
|
||||
activeProfileId = AuthManager.DEFAULT_PROFILE_ID,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
transcription.complete(
|
||||
Response.success(
|
||||
AudioTranscriptionResponse(ok = true, transcript = "do not send this to the new server"),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
// The transcript must not become the initial prompt of the new
|
||||
// scope; it stays in the composer instead (review, PR #1250).
|
||||
verify(exactly = 0) {
|
||||
HermesWsClient.sendMessage(any(), "do not send this to the new server", any(), any())
|
||||
}
|
||||
assertEquals(
|
||||
"do not send this to the new server",
|
||||
viewModel.uiState.value.composerTextToRestore,
|
||||
)
|
||||
assertTrue(
|
||||
viewModel.uiState.value.errorMessage
|
||||
?.contains("transcript kept") == true,
|
||||
)
|
||||
assertFalse(viewModel.uiState.value.isTranscribingVoiceNote)
|
||||
assertFalse(voiceFile.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendVoiceNote_whenServerProfileChangesDuringTranscription_doesNotSendIntoNewScope() =
|
||||
runTest {
|
||||
stubActiveProfile()
|
||||
val api = mockk<com.m57.hermescontrol.data.remote.HermesApiService>(relaxed = true)
|
||||
every { ApiClient.hermesApi } returns api
|
||||
every { ApiClient.transcriptionService(any()) } returns api
|
||||
val transcription = CompletableDeferred<Response<AudioTranscriptionResponse>>()
|
||||
coEvery { api.transcribeAudio(any()) } coAnswers { transcription.await() }
|
||||
|
||||
every { AuthManager.currentDataScope() } returns
|
||||
DataScope(
|
||||
connectionProfileId = "alpha",
|
||||
baseUrl = "http://alpha.test/",
|
||||
activeProfileId = "default",
|
||||
)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
mockConnectionStatus.value = ConnectionStatus.CONNECTED
|
||||
// A pending SESSION_CREATE is all this test needs; skipping the
|
||||
// gateway preload keeps the shared ModelCatalogStore out of it.
|
||||
viewModel.createNewSession()
|
||||
advanceUntilIdle()
|
||||
|
||||
val voiceFile =
|
||||
java.io.File(attachmentCacheDir, "voice_profile_switch.m4a").apply {
|
||||
writeBytes(byteArrayOf(4, 5, 6))
|
||||
}
|
||||
viewModel.sendVoiceNote(voiceFile)
|
||||
advanceUntilIdle()
|
||||
assertTrue(viewModel.uiState.value.isTranscribingVoiceNote)
|
||||
|
||||
// Same server, different server-side Hermes profile: the session
|
||||
// IDs cannot see this change either, so the scope must.
|
||||
every { AuthManager.currentDataScope() } returns
|
||||
DataScope(
|
||||
connectionProfileId = "alpha",
|
||||
baseUrl = "http://alpha.test/",
|
||||
activeProfileId = "work",
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
transcription.complete(
|
||||
Response.success(
|
||||
AudioTranscriptionResponse(ok = true, transcript = "profile-scoped transcript"),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify(exactly = 0) {
|
||||
HermesWsClient.sendMessage(any(), "profile-scoped transcript", any(), any())
|
||||
}
|
||||
assertEquals(
|
||||
"profile-scoped transcript",
|
||||
viewModel.uiState.value.composerTextToRestore,
|
||||
)
|
||||
assertTrue(
|
||||
viewModel.uiState.value.errorMessage
|
||||
?.contains("transcript kept") == true,
|
||||
)
|
||||
assertFalse(viewModel.uiState.value.isTranscribingVoiceNote)
|
||||
assertFalse(voiceFile.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canInterrupt_staysFalseWhileTypingWithNoRuntimeSession_thenTracksTheGeneration() =
|
||||
runTest {
|
||||
stubActiveProfile()
|
||||
val vm = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
mockConnectionStatus.value = ConnectionStatus.CONNECTED
|
||||
mockEventsFlow.emit(WsEvent.GatewayReady(null))
|
||||
advanceUntilIdle()
|
||||
|
||||
val sentPrompts = mutableListOf<String>()
|
||||
every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers {
|
||||
reqCount++
|
||||
val id = "req-msg-$reqCount"
|
||||
sentPrompts += arg<String>(1)
|
||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||
id
|
||||
}
|
||||
|
||||
// Queued while session create is in flight: sending is pending,
|
||||
// but the session has no runtime to interrupt yet, so Stop must stay
|
||||
// hidden and interruptSession() must not be reachable from it
|
||||
// (review, PR #1250).
|
||||
vm.sendMessage("held under session preparation")
|
||||
advanceUntilIdle()
|
||||
assertTrue(vm.uiState.value.isSending)
|
||||
assertFalse(vm.uiState.value.canInterrupt)
|
||||
|
||||
// Once the runtime session exists and the prompt is dispatched,
|
||||
// the running generation is interruptible. Read the captured
|
||||
// session.create id instead of counting sends: a singleton store
|
||||
// warmed by an earlier test can fire an extra model.options send
|
||||
// in this gateway flow and shift the counter (see the resume
|
||||
// tests, same pattern).
|
||||
val createReqId =
|
||||
sentRequestMethods.last { it.first == WsMethods.SESSION_CREATE }.second
|
||||
mockEventsFlow.emit(
|
||||
WsEvent.RpcResult(
|
||||
createReqId,
|
||||
mapOf("session_id" to "session-969", "stored_session_id" to "session-storage-969"),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("held under session preparation"), sentPrompts)
|
||||
// A successful prompt.submit acknowledgement only means the
|
||||
// gateway accepted it; the generation is interruptible only once
|
||||
// the runtime reports message.start for its session.
|
||||
mockEventsFlow.emit(WsEvent.MessageStart("session-969"))
|
||||
advanceUntilIdle()
|
||||
assertTrue(vm.uiState.value.canInterrupt)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.app.Application
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.HermesDatabase
|
||||
import com.m57.hermescontrol.data.model.PaginationInfo
|
||||
import com.m57.hermescontrol.data.model.SessionMessagesResponse
|
||||
@@ -77,6 +78,14 @@ class SlashCommandDispatchRpcTest {
|
||||
// COMMAND_DISPATCH captures in suite order). setMain alone is safe —
|
||||
// the slash-dispatch send path is synchronous (no IO hop).
|
||||
mockkObject(AuthManager)
|
||||
// ChatModelSwitchDelegate.preloadModelOptions() reads pinned models
|
||||
// when the catalog load succeeds; without this stub the spy falls
|
||||
// through to the real AuthManager and throws "not initialized".
|
||||
every { AuthManager.getPinnedModels() } returns emptyList()
|
||||
// The delegate and the shared catalog store both collect this flow;
|
||||
// park them on a never-emitting state so a later real-AuthManager
|
||||
// emission cannot resume a stale Main-dispatched collector.
|
||||
every { AuthManager.dataScopeFlow } returns MutableStateFlow<DataScope?>(null)
|
||||
mockkObject(HermesWsClient)
|
||||
mockkObject(ApiClient)
|
||||
mockkObject(HermesDatabase)
|
||||
@@ -459,7 +468,11 @@ class SlashCommandDispatchRpcTest {
|
||||
every {
|
||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
||||
} answers {
|
||||
val m = methodCalls.last()
|
||||
// Read the method from THIS invocation, not from the shared
|
||||
// capture list: a concurrent request() landing between capture
|
||||
// and answer can leave .last() pointing at another call (CI:
|
||||
// "Value not yet captured" for the submitted prompt).
|
||||
val m = arg<String>(0)
|
||||
val d = CompletableDeferred<Any?>()
|
||||
if (m == WsMethods.COMMAND_DISPATCH) {
|
||||
// Backend rejects /status with the registry-miss 4018 (issue #576).
|
||||
@@ -729,7 +742,9 @@ class SlashCommandDispatchRpcTest {
|
||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
||||
} answers {
|
||||
val d = CompletableDeferred<Any?>()
|
||||
if (methodCalls.last() == WsMethods.COMMAND_DISPATCH) {
|
||||
// Read the method from THIS invocation, not from the shared
|
||||
// capture list (see the 4018 test above).
|
||||
if (arg<String>(0) == WsMethods.COMMAND_DISPATCH) {
|
||||
// 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"))
|
||||
} else {
|
||||
|
||||
Reference in new issue
Block a user