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:
yilsemandClaude Opus 5.5 committed 2026-10-05 17:21:32 +03:00
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"))
}
}