feat(aiturk): hide what the paired IDE cannot serve, plain error sentences, reconnect on return
When paired with AITURK IDE: - an "unknown method" (-32601) answer and the proxy's 403/route-404 are shown as plain sentences, never as raw JsonRpcError text; a method the IDE does not have is not sent again on the same socket - Account connectors, Vault, Keys and Files are hidden (the IDE has no connectors.* / vault.* and its phone proxy refuses /api/env and /api/files) - an idle-closed chat socket is reopened when the app returns to the foreground; idle tunnel connections are dropped on a network change - an unsaved chat no longer polls /api/sessions/<id> - the pairing screen shows the app version A plain Hermes connection is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
This commit is contained in:
1 parent
c2d828b90a
commit
827c74b3c3
18 files changed
+709
-22
No files matched your search
@@ -0,0 +1,305 @@
|
||||
package com.m57.hermescontrol.data.aiturk
|
||||
|
||||
import com.m57.hermescontrol.AccountConnectorsScreen
|
||||
import com.m57.hermescontrol.ChatScreen
|
||||
import com.m57.hermescontrol.FilesScreen
|
||||
import com.m57.hermescontrol.KeysScreen
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.SettingsVault
|
||||
import com.m57.hermescontrol.data.remote.NetworkError
|
||||
import com.m57.hermescontrol.data.remote.mapHttpError
|
||||
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.ReplayTracker
|
||||
import com.m57.hermescontrol.data.ws.RpcChannel
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
|
||||
import com.m57.hermescontrol.ui.aiturkpair.AiturkFeatureGate
|
||||
import com.m57.hermescontrol.ui.chat.ChatUiState
|
||||
import com.m57.hermescontrol.ui.chat.ChatWsEventReducer
|
||||
import com.m57.hermescontrol.ui.chat.StreamingState
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* A paired AITURK IDE does not have every method and route this app knows.
|
||||
* The user must never read `JsonRpcError(code=-32601, …)` or `unknown method`,
|
||||
* a missing method must not be asked for again and again, and none of this may
|
||||
* change how a plain Hermes dashboard connection behaves.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AiturkCompatTest {
|
||||
private val unavailable = "Bu özellik AITURK IDE'nin bu sürümünde yok."
|
||||
private val forbidden = "Güvenlik nedeniyle bu işlem telefondan yapılamaz."
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
AiturkCompat.resetForTest()
|
||||
AiturkCompat.stringLookup = { id ->
|
||||
when (id) {
|
||||
R.string.aiturk_feature_unavailable -> unavailable
|
||||
R.string.aiturk_phone_forbidden -> forbidden
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() = AiturkCompat.resetForTest()
|
||||
|
||||
private class Gateway(
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
val sent = mutableListOf<Pair<String, String>>()
|
||||
val events = mutableListOf<WsEvent>()
|
||||
val channel =
|
||||
RpcChannel(
|
||||
scope = scope,
|
||||
json = Json { ignoreUnknownKeys = true },
|
||||
sendRequest = { method, _, onSent ->
|
||||
val id = "req-${sent.size + 1}"
|
||||
sent.add(id to method)
|
||||
onSent?.invoke(id)
|
||||
id
|
||||
},
|
||||
sendFrame = { true },
|
||||
removeQueuedMessage = {},
|
||||
onIdleCheck = {},
|
||||
)
|
||||
val replay =
|
||||
ReplayTracker(
|
||||
scope = scope,
|
||||
fetchEventsSince = { SessionEventsSinceResult() },
|
||||
emitEvent = { events.add(it) },
|
||||
)
|
||||
|
||||
fun answerUnknownMethod(
|
||||
id: String,
|
||||
method: String,
|
||||
) = channel.handleIncomingFrame(
|
||||
"""{"jsonrpc":"2.0","id":"$id","error":{"code":-32601,"message":"unknown method: $method"}}""",
|
||||
replay,
|
||||
) { events.add(it) }
|
||||
}
|
||||
|
||||
private fun failure(deferred: CompletableDeferred<Any?>): HermesWsClient.HermesRpcException =
|
||||
deferred.getCompletionExceptionOrNull() as HermesWsClient.HermesRpcException
|
||||
|
||||
// ── JSON-RPC ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun unknownMethodOnPairedIdeFailsQuietlyWithAPlainSentence() =
|
||||
runTest {
|
||||
AiturkCompat.linkedOverride = true
|
||||
val gateway = Gateway(this)
|
||||
|
||||
val call = gateway.channel.request("connectors.list")
|
||||
gateway.answerUnknownMethod("req-1", "connectors.list")
|
||||
|
||||
// No RpcError event: the chat reducer would have turned it into a banner.
|
||||
assertTrue(gateway.events.isEmpty(), "events: ${gateway.events}")
|
||||
val error = failure(call)
|
||||
assertEquals(-32601, error.code)
|
||||
assertEquals(unavailable, error.message)
|
||||
assertFalse(error.message.orEmpty().contains("unknown method"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMissingMethodIsNotSentAgainUntilTheNextSocket() =
|
||||
runTest {
|
||||
AiturkCompat.linkedOverride = true
|
||||
val gateway = Gateway(this)
|
||||
gateway.channel.request("connectors.list")
|
||||
gateway.answerUnknownMethod("req-1", "connectors.list")
|
||||
|
||||
val again = gateway.channel.request("connectors.list")
|
||||
|
||||
assertEquals(listOf("req-1" to "connectors.list"), gateway.sent)
|
||||
assertEquals(-32601, failure(again).code)
|
||||
assertEquals(unavailable, failure(again).message)
|
||||
assertTrue(gateway.events.isEmpty())
|
||||
|
||||
// Another method is unaffected, and a new socket (maybe a newer IDE) asks again.
|
||||
gateway.channel.request("session.list")
|
||||
AiturkRelayStatus.onSocketOpened()
|
||||
gateway.channel.request("connectors.list")
|
||||
assertEquals(listOf("connectors.list", "session.list", "connectors.list"), gateway.sent.map { it.second })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plainHermesConnectionKeepsTheUpstreamBehaviour() =
|
||||
runTest {
|
||||
AiturkCompat.linkedOverride = false
|
||||
val gateway = Gateway(this)
|
||||
|
||||
val call = gateway.channel.request("connectors.list")
|
||||
gateway.answerUnknownMethod("req-1", "connectors.list")
|
||||
|
||||
assertEquals(1, gateway.events.size)
|
||||
assertTrue(gateway.events.single() is WsEvent.RpcError)
|
||||
assertEquals("unknown method: connectors.list", failure(call).message)
|
||||
gateway.channel.request("connectors.list")
|
||||
assertEquals(2, gateway.sent.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun otherRpcErrorsOnPairedIdeStillReachTheirConsumers() =
|
||||
runTest {
|
||||
AiturkCompat.linkedOverride = true
|
||||
val gateway = Gateway(this)
|
||||
|
||||
val call = gateway.channel.request("session.resume")
|
||||
gateway.channel.handleIncomingFrame(
|
||||
"""{"jsonrpc":"2.0","id":"req-1","error":{"code":4001,"message":"session not found"}}""",
|
||||
gateway.replay,
|
||||
) { gateway.events.add(it) }
|
||||
|
||||
assertEquals(1, gateway.events.size)
|
||||
assertEquals("session not found", failure(call).message)
|
||||
gateway.channel.request("session.resume")
|
||||
assertEquals(2, gateway.sent.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fireAndForgetUnknownMethodStillEmitsItsEvent() =
|
||||
runTest {
|
||||
// ChatViewModel needs this event to fall back from session.branch_whole to session.branch.
|
||||
AiturkCompat.linkedOverride = true
|
||||
val gateway = Gateway(this)
|
||||
|
||||
gateway.answerUnknownMethod("77", "session.branch_whole")
|
||||
|
||||
assertEquals(1, gateway.events.size)
|
||||
assertEquals(-32601, (gateway.events.single() as WsEvent.RpcError).error.code)
|
||||
}
|
||||
|
||||
// ── Banner text ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun chatBannerNeverShowsTheRawErrorOnPairedIde() {
|
||||
AiturkCompat.linkedOverride = true
|
||||
val error = JsonRpcError(-32601, "unknown method: connectors.list")
|
||||
|
||||
val reduced =
|
||||
ChatWsEventReducer.reduce(ChatUiState(), StreamingState(), WsEvent.RpcError("9", error), null)
|
||||
|
||||
assertEquals(unavailable, reduced.state.errorMessage)
|
||||
val other = JsonRpcError(4001, "session not found")
|
||||
assertEquals("session not found", AiturkCompat.rpcBannerText(other, legacy = "Error: $other"))
|
||||
assertEquals(unavailable, AiturkCompat.rpcBannerText(mapOf("code" to -32601), legacy = "x"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatBannerIsUnchangedForPlainHermes() {
|
||||
AiturkCompat.linkedOverride = false
|
||||
val error = JsonRpcError(-32601, "unknown method: connectors.list")
|
||||
|
||||
val reduced =
|
||||
ChatWsEventReducer.reduce(ChatUiState(), StreamingState(), WsEvent.RpcError("9", error), null)
|
||||
|
||||
assertEquals("Error: $error", reduced.state.errorMessage)
|
||||
}
|
||||
|
||||
// ── REST ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun proxyRefusalAndMissingRouteGetPlainSentences() {
|
||||
AiturkCompat.linkedOverride = true
|
||||
|
||||
assertEquals(forbidden, mapHttpError(403, """{"error":"forbidden"}""").message)
|
||||
assertEquals(unavailable, mapHttpError(404, """{"detail":"Not Found"}""").message)
|
||||
assertEquals(unavailable, mapHttpError(404, """{"error":"not_found"}""").message)
|
||||
assertEquals(403, (mapHttpError(403, """{"error":"forbidden"}""") as NetworkError.Http).code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun realRecordErrorsAndPlainHermesKeepTheirOwnText() {
|
||||
AiturkCompat.linkedOverride = true
|
||||
assertEquals("Session not found", mapHttpError(404, """{"detail":"Session not found"}""").message)
|
||||
assertNull(AiturkCompat.httpErrorMessage(403, """{"detail":"Profile is read-only"}"""))
|
||||
assertNull(AiturkCompat.httpErrorMessage(500, """{"error":"forbidden"}"""))
|
||||
assertNull(AiturkCompat.httpErrorMessage(403, "not json"))
|
||||
|
||||
AiturkCompat.linkedOverride = false
|
||||
assertNull(AiturkCompat.httpErrorMessage(403, """{"error":"forbidden"}"""))
|
||||
assertTrue(mapHttpError(403, """{"error":"forbidden"}""").message.startsWith("Forbidden (HTTP 403)"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unsavedChatSkipsTheSessionDetailPollOnlyOnPairedIde() {
|
||||
AiturkCompat.linkedOverride = true
|
||||
assertTrue(AiturkCompat.skipSessionDetail(transcriptEmpty = true))
|
||||
assertFalse(AiturkCompat.skipSessionDetail(transcriptEmpty = false))
|
||||
AiturkCompat.linkedOverride = false
|
||||
assertFalse(AiturkCompat.skipSessionDetail(transcriptEmpty = true))
|
||||
}
|
||||
|
||||
// ── Network and lifecycle ────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun returningToTheForegroundReopensAnIdleClosedSocket() {
|
||||
AiturkCompat.linkedOverride = true
|
||||
var connects = 0
|
||||
|
||||
AiturkCompat.onAppForeground(ConnectionStatus.DISCONNECTED, autoReconnect = { true }) { connects++ }
|
||||
assertEquals(1, connects)
|
||||
|
||||
// Open, opening, signed out or switched off by the user: left alone.
|
||||
for (status in ConnectionStatus.entries - ConnectionStatus.DISCONNECTED) {
|
||||
AiturkCompat.onAppForeground(status, autoReconnect = { true }) { connects++ }
|
||||
}
|
||||
AiturkCompat.onAppForeground(ConnectionStatus.DISCONNECTED, autoReconnect = { false }) { connects++ }
|
||||
AiturkCompat.linkedOverride = false
|
||||
AiturkCompat.onAppForeground(ConnectionStatus.DISCONNECTED, autoReconnect = { true }) { connects++ }
|
||||
assertEquals(1, connects)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun networkChangeDropsIdleTunnelConnectionsOnlyOnPairedIde() {
|
||||
var evictions = 0
|
||||
AiturkCompat.linkedOverride = true
|
||||
AiturkCompat.onNetworkChanged { evictions++ }
|
||||
assertEquals(1, evictions)
|
||||
|
||||
AiturkCompat.linkedOverride = false
|
||||
AiturkCompat.onNetworkChanged { evictions++ }
|
||||
assertEquals(1, evictions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNewSocketClearsTheIdeClosedNote() {
|
||||
AiturkRelayStatus.onSocketEnded(AiturkTunnelException(AiturkTunnelException.HOST_OFFLINE, 4503))
|
||||
assertTrue(AiturkRelayStatus.hostOffline.value)
|
||||
|
||||
AiturkRelayStatus.onSocketOpened()
|
||||
|
||||
assertFalse(AiturkRelayStatus.hostOffline.value)
|
||||
}
|
||||
|
||||
// ── Screens ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun screensThePairedIdeCannotServeAreHidden() {
|
||||
for (key in listOf(AccountConnectorsScreen, KeysScreen, FilesScreen, SettingsVault)) {
|
||||
assertFalse(AiturkFeatureGate.shows(key, linked = true), "$key")
|
||||
assertTrue(AiturkFeatureGate.shows(key, linked = false), "$key")
|
||||
}
|
||||
assertTrue(AiturkFeatureGate.shows(ChatScreen, linked = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun versionLabelShowsNameAndBuildNumber() {
|
||||
assertEquals("v1.4.0 (37)", AiturkCompat.versionLabel("1.4.0", 37))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.m57.hermescontrol.data.aiturk
|
||||
|
||||
import android.util.Log
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import okhttp3.WebSocket
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The chat socket opening again (IDE restarted, network back) must clear the
|
||||
* "AITURK IDE is closed" note and forget which methods the previous IDE
|
||||
* process lacked. This drives the real socket listener, not the helper.
|
||||
*/
|
||||
class AiturkSocketOpenHookTest {
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
mockkStatic(Log::class)
|
||||
every { Log.d(any<String>(), any<String>()) } returns 0
|
||||
every { Log.i(any<String>(), any<String>()) } returns 0
|
||||
every { Log.w(any<String>(), any<String>()) } returns 0
|
||||
AiturkCompat.resetForTest()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
AiturkCompat.resetForTest()
|
||||
AiturkRelayStatus.onSocketEnded(null)
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun openingTheChatSocketClearsIdeClosedNoteAndMissingMethods() {
|
||||
AiturkCompat.linkedOverride = true
|
||||
AiturkRelayStatus.onSocketEnded(AiturkTunnelException(AiturkTunnelException.HOST_OFFLINE, 4503))
|
||||
AiturkCompat.noteRpcError("connectors.list", AiturkCompat.METHOD_NOT_FOUND)
|
||||
assertTrue(AiturkRelayStatus.hostOffline.value)
|
||||
assertTrue(AiturkCompat.isKnownMissing("connectors.list"))
|
||||
|
||||
val socket = mockk<WebSocket>(relaxed = true)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectionGenerationForTest.set(7)
|
||||
HermesWsClient.createListenerForTest(7).onOpen(socket, mockk(relaxed = true))
|
||||
|
||||
assertTrue(HermesWsClient.isConnected)
|
||||
assertFalse(AiturkRelayStatus.hostOffline.value)
|
||||
assertFalse(AiturkCompat.isKnownMissing("connectors.list"))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user