From 4e4959b283fe7761d3b0708be33acfcb2ef6ff28 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 23:06:51 -0700 Subject: [PATCH] refactor(android): consolidate chat controller test setup (#117862) --- .../chat/ChatControllerCommandControlsTest.kt | 575 ++++------ .../chat/ChatControllerModelSelectionTest.kt | 982 +++++++----------- .../app/chat/ChatControllerTerminalAckTest.kt | 208 ++-- .../chat/ChatControllerTranscriptCacheTest.kt | 680 +++++------- .../ai/openclaw/app/chat/ChatReplayHarness.kt | 92 ++ 5 files changed, 961 insertions(+), 1576 deletions(-) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerCommandControlsTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerCommandControlsTest.kt index 70679643a951..28b226398514 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerCommandControlsTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerCommandControlsTest.kt @@ -8,13 +8,13 @@ import kotlinx.coroutines.async import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +@OptIn(ExperimentalCoroutinesApi::class) class ChatControllerCommandControlsTest { - private val json = Json { ignoreUnknownKeys = true } + private val json = chatControllerTestJson @Test fun parseChatCommandsKeepsTextAliasesAndArgumentFlag() { @@ -52,35 +52,13 @@ class ChatControllerCommandControlsTest { assertEquals(true, commands[1].acceptsArgs) } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun healthEventRefreshesCommandsAfterReconnect() = runTest { - val requests = mutableListOf>() - val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - requests += method to paramsJson - when (method) { - "chat.metadata" -> - """ - { - "commands": [ - { - "name": "model", - "description": "Switch models", - "textAliases": ["/model"], - "acceptsArgs": true - } - ] - } - """.trimIndent() - else -> "{}" - } - }, - ) + val (controller, requests) = + chatControllerTestSetup { + respond("chat.metadata", commandResponse("model", "Switch models", acceptsArgs = true)) + } controller.handleGatewayEvent("health", null) advanceUntilIdle() @@ -105,52 +83,21 @@ class ChatControllerCommandControlsTest { assertEquals(2, requests.count { it.first == "chat.metadata" }) } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun commandListScopesToActiveAgentAndRefreshesAfterAgentSwitch() = runTest { - val requests = mutableListOf>() - val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - requests += method to paramsJson - when (method) { - "chat.metadata" -> - if (paramsJson.orEmpty().contains("\"agentId\":\"ops\"")) { - """ - { - "commands": [ - { - "name": "ops", - "description": "Ops command", - "textAliases": ["/ops"], - "acceptsArgs": false - } - ] - } - """.trimIndent() - } else { - """ - { - "commands": [ - { - "name": "main", - "description": "Main command", - "textAliases": ["/main"], - "acceptsArgs": false - } - ] - } - """.trimIndent() - } - "chat.history" -> """{"sessionId":"loaded-session","messages":[]}""" - "health" -> "{}" - else -> "{}" + val (controller, requests) = + chatControllerTestSetup { + respond("chat.metadata") { paramsJson -> + if (paramsJson.orEmpty().contains("\"agentId\":\"ops\"")) { + commandResponse("ops", "Ops command") + } else { + commandResponse("main", "Main command") } - }, - ) + } + respond("chat.history", """{"sessionId":"loaded-session","messages":[]}""") + respond("health", "{}") + } controller.handleGatewayEvent("health", null) advanceUntilIdle() @@ -175,17 +122,13 @@ class ChatControllerCommandControlsTest { assertTrue(commandRequests.any { it.second.orEmpty().contains("\"agentId\":\"ops\"") }) } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun delayedCommandListFromPreviousGatewayCannotReplaceCurrentCommands() = runTest { var cacheScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) val gatewayAResponse = CompletableDeferred() val controller = - ChatController( - scope = this, - json = json, - requestGateway = { _, _ -> error("gateway-bound request expected") }, + createChatController( requestGatewayForGateway = { gatewayId, method, _ -> require(method == "chat.metadata") if (gatewayId == "gateway-a") { @@ -195,7 +138,7 @@ class ChatControllerCommandControlsTest { } }, cacheScope = { cacheScope }, - ) + ) { _, _ -> error("gateway-bound request expected") } controller.refreshCommands() runCurrent() @@ -221,26 +164,16 @@ class ChatControllerCommandControlsTest { ) } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun startNewChatCreatesWriteScopedSessionAndReloadsHistory() = runTest { - val requests = mutableListOf>() - val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - requests += method to paramsJson - when (method) { - "sessions.create" -> """{"ok":true,"key":"agent:main:dashboard:fresh"}""" - "chat.history" -> """{"sessionId":"fresh-session","messages":[]}""" - "health" -> "{}" - "sessions.list" -> """{"sessions":[]}""" - else -> "{}" - } - }, - ) + val (controller, requests) = + chatControllerTestSetup { + respond("sessions.create", """{"ok":true,"key":"agent:main:dashboard:fresh"}""") + respond("chat.history", """{"sessionId":"fresh-session","messages":[]}""") + respond("health", "{}") + respond("sessions.list", """{"sessions":[]}""") + } controller.handleGatewayEvent("health", null) controller.load("main") advanceUntilIdle() @@ -259,39 +192,29 @@ class ChatControllerCommandControlsTest { assertTrue(requests.any { it.first == "sessions.list" }) } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun startNewChatRetriesWithoutParentLifecycleAgainstOlderGateway() = runTest { - val requests = mutableListOf>() var createCalls = 0 - val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - requests += method to paramsJson - when (method) { - "sessions.create" -> { - createCalls += 1 - if (createCalls == 1) { - throw GatewayRequestRejected( - GatewaySession.ErrorShape( - code = "INVALID_REQUEST", - message = - "invalid sessions.create params: at root: unexpected property 'succeedsParent'", - ), - ) - } - """{"ok":true,"key":"agent:main:dashboard:fresh"}""" - } - "chat.history" -> """{"sessionId":"fresh-session","messages":[]}""" - "health" -> "{}" - "sessions.list" -> """{"sessions":[]}""" - else -> "{}" + val (controller, requests) = + chatControllerTestSetup { + respond("sessions.create") { paramsJson -> + createCalls += 1 + if (createCalls == 1) { + throw GatewayRequestRejected( + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = + "invalid sessions.create params: at root: unexpected property 'succeedsParent'", + ), + ) } - }, - ) + """{"ok":true,"key":"agent:main:dashboard:fresh"}""" + } + respond("chat.history", """{"sessionId":"fresh-session","messages":[]}""") + respond("health", "{}") + respond("sessions.list", """{"sessions":[]}""") + } controller.handleGatewayEvent("health", null) controller.load("main") advanceUntilIdle() @@ -309,26 +232,16 @@ class ChatControllerCommandControlsTest { assertEquals("agent:main:dashboard:fresh", controller.sessionKey.value) } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun startNewChatInWorktreeIncludesWorktreeFlag() = runTest { - val requests = mutableListOf>() - val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - requests += method to paramsJson - when (method) { - "sessions.create" -> """{"ok":true,"key":"agent:main:dashboard:worktree"}""" - "chat.history" -> """{"sessionId":"worktree-session","messages":[]}""" - "health" -> "{}" - "sessions.list" -> """{"sessions":[]}""" - else -> "{}" - } - }, - ) + val (controller, requests) = + chatControllerTestSetup { + respond("sessions.create", """{"ok":true,"key":"agent:main:dashboard:worktree"}""") + respond("chat.history", """{"sessionId":"worktree-session","messages":[]}""") + respond("health", "{}") + respond("sessions.list", """{"sessions":[]}""") + } controller.handleGatewayEvent("health", null) controller.load("main") advanceUntilIdle() @@ -342,20 +255,11 @@ class ChatControllerCommandControlsTest { @Test fun sessionMutationsSendGatewayContractsAndRefresh() = runTest { - val requests = mutableListOf>() - val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - requests += method to paramsJson - when (method) { - "sessions.list" -> """{"sessions":[]}""" - "sessions.delete" -> """{"deleted":true}""" - else -> "{}" - } - }, - ) + val (controller, requests) = + chatControllerTestSetup { + respond("sessions.list", """{"sessions":[]}""") + respond("sessions.delete", """{"deleted":true}""") + } controller.patchSession( key = "main", @@ -386,24 +290,16 @@ class ChatControllerCommandControlsTest { @Test fun renameSessionGroupPatchesEveryMemberIncludingArchivedOnlyOnes() = runTest { - val requests = mutableListOf>() - val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - requests += method to paramsJson - when (method) { - "sessions.list" -> - if (paramsJson.orEmpty().contains("\"archived\":true")) { - """{"sessions":[{"key":"agent:main:active","category":"Work"},{"key":"agent:main:archived","category":" Work "}]}""" - } else { - """{"sessions":[{"key":"agent:main:active","category":"Work"},{"key":"agent:main:other","category":"Play"}]}""" - } - else -> "{}" + val (controller, requests) = + chatControllerTestSetup { + respond("sessions.list") { paramsJson -> + if (paramsJson.orEmpty().contains("\"archived\":true")) { + """{"sessions":[{"key":"agent:main:active","category":"Work"},{"key":"agent:main:archived","category":" Work "}]}""" + } else { + """{"sessions":[{"key":"agent:main:active","category":"Work"},{"key":"agent:main:other","category":"Play"}]}""" } - }, - ) + } + } controller.renameSessionGroup(from = "Work", to = "Focus") @@ -424,29 +320,21 @@ class ChatControllerCommandControlsTest { @Test fun dissolveSessionGroupClearsCategoriesBestEffort() = runTest { - val requests = mutableListOf>() var patchCount = 0 - val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - requests += method to paramsJson - when (method) { - "sessions.list" -> - if (paramsJson.orEmpty().contains("\"archived\":true")) { - """{"sessions":[{"key":"agent:main:archived","category":"Work"}]}""" - } else { - """{"sessions":[{"key":"agent:main:a","category":"Work"},{"key":"agent:main:b","category":"Work"}]}""" - } - "sessions.patch" -> { - patchCount += 1 - if (patchCount == 1) throw RuntimeException("offline") else "{}" - } - else -> "{}" + val (controller, requests) = + chatControllerTestSetup { + respond("sessions.list") { paramsJson -> + if (paramsJson.orEmpty().contains("\"archived\":true")) { + """{"sessions":[{"key":"agent:main:archived","category":"Work"}]}""" + } else { + """{"sessions":[{"key":"agent:main:a","category":"Work"},{"key":"agent:main:b","category":"Work"}]}""" } - }, - ) + } + respond("sessions.patch") { paramsJson -> + patchCount += 1 + if (patchCount == 1) throw RuntimeException("offline") else "{}" + } + } controller.dissolveSessionGroup("Work") @@ -460,20 +348,11 @@ class ChatControllerCommandControlsTest { @Test fun forkSessionReturnsCreatedKeyAndRefreshesActiveSessions() = runTest { - val requests = mutableListOf>() - val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - requests += method to paramsJson - when (method) { - "sessions.create" -> """{"session":{"key":"agent:main:forked"}}""" - "sessions.list" -> """{"sessions":[]}""" - else -> "{}" - } - }, - ) + val (controller, requests) = + chatControllerTestSetup { + respond("sessions.create", """{"session":{"key":"agent:main:forked"}}""") + respond("sessions.list", """{"sessions":[]}""") + } val key = controller.forkSession("main") @@ -506,23 +385,13 @@ class ChatControllerCommandControlsTest { ) } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun archivedSessionListAndOpenUnreadSessionUsePatchContracts() = runTest { - val requests = mutableListOf>() - val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - requests += method to paramsJson - when (method) { - "sessions.list" -> """{"sessions":[{"key":"main","unread":true}]}""" - else -> "{}" - } - }, - ) + val (controller, requests) = + chatControllerTestSetup { + respond("sessions.list", """{"sessions":[{"key":"main","unread":true}]}""") + } controller.refreshSessions(archived = true) advanceUntilIdle() @@ -544,21 +413,13 @@ class ChatControllerCommandControlsTest { assertTrue(patch.contains("\"unread\":false")) } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun sessionEventsApplyExplicitLabelAndCategoryClears() = runTest { val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - when (method) { - "sessions.list" -> """{"sessions":[{"key":"main","label":"Named","category":"Work"}]}""" - else -> "{}" - } - }, - ) + createScriptedChatController { + respond("sessions.list", """{"sessions":[{"key":"main","label":"Named","category":"Work"}]}""") + } controller.refreshSessions() advanceUntilIdle() @@ -580,25 +441,17 @@ class ChatControllerCommandControlsTest { assertEquals(null, merged.category) } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun failedReadAcknowledgementUnlatchesForRetry() = runTest { - val requests = mutableListOf>() var failPatches = true - val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - requests += method to paramsJson - when (method) { - "sessions.patch" -> if (failPatches) throw RuntimeException("offline") else "{}" - "sessions.list" -> """{"sessions":[{"key":"main","unread":true}]}""" - else -> "{}" - } - }, - ) + val (controller, requests) = + chatControllerTestSetup { + respond("sessions.patch") { paramsJson -> + if (failPatches) throw RuntimeException("offline") else "{}" + } + respond("sessions.list", """{"sessions":[{"key":"main","unread":true}]}""") + } controller.refreshSessions() advanceUntilIdle() @@ -616,24 +469,14 @@ class ChatControllerCommandControlsTest { assertEquals(2, requests.count { it.first == "sessions.patch" }) } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun archivingOrDeletingTheOpenSessionFallsBackToMain() = runTest { - val requests = mutableListOf>() - val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - requests += method to paramsJson - when (method) { - "sessions.list" -> """{"sessions":[{"key":"agent:main:side"}]}""" - "sessions.delete" -> """{"deleted":true}""" - else -> "{}" - } - }, - ) + val (controller, requests) = + chatControllerTestSetup { + respond("sessions.list", """{"sessions":[{"key":"agent:main:side"}]}""") + respond("sessions.delete", """{"deleted":true}""") + } controller.switchSession("agent:main:side") advanceUntilIdle() @@ -650,23 +493,13 @@ class ChatControllerCommandControlsTest { assertEquals("main", controller.sessionKey.value) } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun openSessionReacknowledgesUnreadOncePerEpisode() = runTest { - val requests = mutableListOf>() - val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - requests += method to paramsJson - when (method) { - "sessions.list" -> """{"sessions":[{"key":"main","unread":false}]}""" - else -> "{}" - } - }, - ) + val (controller, requests) = + chatControllerTestSetup { + respond("sessions.list", """{"sessions":[{"key":"main","unread":false}]}""") + } controller.refreshSessions() advanceUntilIdle() @@ -699,22 +532,13 @@ class ChatControllerCommandControlsTest { @Test fun startNewChatWithoutLoadedParentCreatesFirstSession() = runTest { - val requests = mutableListOf>() - val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - requests += method to paramsJson - when (method) { - "sessions.create" -> """{"ok":true,"key":"agent:main:dashboard:first"}""" - "chat.history" -> """{"sessionId":"first-session","messages":[]}""" - "health" -> "{}" - "sessions.list" -> """{"sessions":[]}""" - else -> "{}" - } - }, - ) + val (controller, requests) = + chatControllerTestSetup { + respond("sessions.create", """{"ok":true,"key":"agent:main:dashboard:first"}""") + respond("chat.history", """{"sessionId":"first-session","messages":[]}""") + respond("health", "{}") + respond("sessions.list", """{"sessions":[]}""") + } controller.handleGatewayEvent("health", null) assertTrue(controller.startNewChatAwait()) @@ -726,34 +550,25 @@ class ChatControllerCommandControlsTest { assertEquals("agent:main:dashboard:first", controller.sessionKey.value) } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun startNewChatUsesNextAvailableNewChatLabel() = runTest { - val requests = mutableListOf>() - val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - requests += method to paramsJson - when (method) { - "sessions.create" -> """{"ok":true,"key":"agent:main:dashboard:fresh-3"}""" - "chat.history" -> """{"sessionId":"fresh-session-3","messages":[]}""" - "health" -> "{}" - "sessions.list" -> - """ - { - "sessions": [ - {"key":"agent:main:dashboard:fresh","displayName":"New chat"}, - {"key":"agent:main:dashboard:fresh-2","displayName":"New chat 2"} - ] - } - """.trimIndent() - else -> "{}" + val (controller, requests) = + chatControllerTestSetup { + respond("sessions.create", """{"ok":true,"key":"agent:main:dashboard:fresh-3"}""") + respond("chat.history", """{"sessionId":"fresh-session-3","messages":[]}""") + respond("health", "{}") + respond("sessions.list") { paramsJson -> + """ + { + "sessions": [ + {"key":"agent:main:dashboard:fresh","displayName":"New chat"}, + {"key":"agent:main:dashboard:fresh-2","displayName":"New chat 2"} + ] } - }, - ) + """.trimIndent() + } + } controller.handleGatewayEvent("health", null) controller.refreshSessions() advanceUntilIdle() @@ -765,26 +580,16 @@ class ChatControllerCommandControlsTest { assertEquals("agent:main:dashboard:fresh-3", controller.sessionKey.value) } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun startNewChatScopesCreateToActiveAgentSession() = runTest { - val requests = mutableListOf>() - val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - requests += method to paramsJson - when (method) { - "sessions.create" -> """{"ok":true,"key":"agent:ops:dashboard:fresh"}""" - "chat.history" -> """{"sessionId":"ops-session","messages":[]}""" - "health" -> "{}" - "sessions.list" -> """{"sessions":[]}""" - else -> "{}" - } - }, - ) + val (controller, requests) = + chatControllerTestSetup { + respond("sessions.create", """{"ok":true,"key":"agent:ops:dashboard:fresh"}""") + respond("chat.history", """{"sessionId":"ops-session","messages":[]}""") + respond("health", "{}") + respond("sessions.list", """{"sessions":[]}""") + } controller.switchSession("agent:ops:dashboard:parent") advanceUntilIdle() @@ -800,20 +605,11 @@ class ChatControllerCommandControlsTest { @Test fun bareNewSlashCommandUsesGatewayChatCommandPath() = runTest { - val requests = mutableListOf>() - val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - requests += method to paramsJson - when (method) { - "chat.send" -> """{"runId":"run-new"}""" - "health" -> "{}" - else -> "{}" - } - }, - ) + val (controller, requests) = + chatControllerTestSetup { + respond("chat.send", """{"runId":"run-new"}""") + respond("health", "{}") + } controller.handleGatewayEvent("health", null) assertTrue(controller.sendMessageAwaitAcceptance("/new", "off", emptyList())) @@ -826,20 +622,11 @@ class ChatControllerCommandControlsTest { @Test fun startNewChatRejectsWhileRunPending() = runTest { - val requests = mutableListOf>() - val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - requests += method to paramsJson - when (method) { - "chat.send" -> """{"runId":"run-1"}""" - "health" -> "{}" - else -> "{}" - } - }, - ) + val (controller, requests) = + chatControllerTestSetup { + respond("chat.send", """{"runId":"run-1"}""") + respond("health", "{}") + } controller.handleGatewayEvent("health", null) assertTrue(controller.sendMessageAwaitAcceptance("hello", "off", emptyList())) @@ -848,34 +635,24 @@ class ChatControllerCommandControlsTest { assertTrue(requests.none { it.first == "sessions.create" }) } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun startNewChatRejectsDuplicateCreateWhileFirstRequestIsPending() = runTest { - val requests = mutableListOf>() val createEntered = CompletableDeferred() val releaseCreate = CompletableDeferred() var createCount = 0 - val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - requests += method to paramsJson - when (method) { - "sessions.create" -> { - createCount += 1 - createEntered.complete(Unit) - releaseCreate.await() - """{"ok":true,"key":"agent:main:dashboard:fresh"}""" - } - "chat.history" -> """{"sessionId":"fresh-session","messages":[]}""" - "health" -> "{}" - "sessions.list" -> """{"sessions":[]}""" - else -> "{}" - } - }, - ) + val (controller, requests) = + chatControllerTestSetup { + respond("sessions.create") { paramsJson -> + createCount += 1 + createEntered.complete(Unit) + releaseCreate.await() + """{"ok":true,"key":"agent:main:dashboard:fresh"}""" + } + respond("chat.history", """{"sessionId":"fresh-session","messages":[]}""") + respond("health", "{}") + respond("sessions.list", """{"sessions":[]}""") + } controller.handleGatewayEvent("health", null) val first = async { controller.startNewChatAwait() } @@ -891,30 +668,25 @@ class ChatControllerCommandControlsTest { assertEquals(1, requests.count { it.first == "sessions.create" }) } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun startNewChatIgnoresStaleCreateResponseAfterSessionSwitch() = runTest { val requests = mutableListOf>() lateinit var controller: ChatController controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - requests += method to paramsJson - when (method) { - "sessions.create" -> { - controller.switchSession("agent:main:dashboard:other") - """{"ok":true,"key":"agent:main:dashboard:fresh"}""" - } - "chat.history" -> """{"sessionId":"other-session","messages":[]}""" - "health" -> "{}" - "sessions.list" -> """{"sessions":[]}""" - else -> "{}" + createChatController { method, paramsJson -> + requests += method to paramsJson + when (method) { + "sessions.create" -> { + controller.switchSession("agent:main:dashboard:other") + """{"ok":true,"key":"agent:main:dashboard:fresh"}""" } - }, - ) + "chat.history" -> """{"sessionId":"other-session","messages":[]}""" + "health" -> "{}" + "sessions.list" -> """{"sessions":[]}""" + else -> "{}" + } + } controller.handleGatewayEvent("health", null) assertEquals(false, controller.startNewChatAwait()) @@ -924,5 +696,12 @@ class ChatControllerCommandControlsTest { assertTrue(requests.any { it.first == "sessions.create" }) } - private fun commandResponse(name: String): String = """{"commands":[{"name":"$name","textAliases":["/$name"],"acceptsArgs":false}]}""" + private fun commandResponse( + name: String, + description: String? = null, + acceptsArgs: Boolean = false, + ): String { + val descriptionJson = description?.let { ""","description":"$it"""" }.orEmpty() + return """{"commands":[{"name":"$name"$descriptionJson,"textAliases":["/$name"],"acceptsArgs":$acceptsArgs}]}""" + } } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerModelSelectionTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerModelSelectionTest.kt index 3c59239fdbde..a5d4ca36e5ff 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerModelSelectionTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerModelSelectionTest.kt @@ -7,7 +7,6 @@ import kotlinx.coroutines.async import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlinx.coroutines.yield -import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import org.junit.Assert.assertEquals @@ -16,24 +15,18 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +@OptIn(ExperimentalCoroutinesApi::class) class ChatControllerModelSelectionTest { - private val json = Json { ignoreUnknownKeys = true } + private val json = chatControllerTestJson @Test fun successfulSelectionRecordsRecentAndUpdatesSelectedModel() = runTest { - val requests = mutableListOf>() val recents = mutableListOf() - val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - requests += method to paramsJson - "{}" - }, - recordModelRecent = recents::add, - ) + val (controller, requests) = + chatControllerTestSetup { + recordModelRecent = recents::add + } assertTrue(controller.setSessionModelAwait("main", " anthropic/claude-opus-4 ")) @@ -49,33 +42,11 @@ class ChatControllerModelSelectionTest { fun successfulSelectionAppliesGatewayThinkingLevelsAndEffectiveLevel() = runTest { val controller = - ChatController( - scope = this, - json = json, - requestGateway = { _, paramsJson -> - val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject - val acceptedThinking = (params["thinkingLevel"] as? JsonPrimitive)?.content ?: "max" - """ - { - "resolved": { - "modelProvider": "anthropic", - "model": "claude-sonnet-5", - "thinkingLevel": "$acceptedThinking", - "thinkingLevels": [ - {"id": "off", "label": "off"}, - {"id": "minimal", "label": "minimal"}, - {"id": "low", "label": "low"}, - {"id": "medium", "label": "medium"}, - {"id": "high", "label": "high"}, - {"id": "xhigh", "label": "xhigh"}, - {"id": "adaptive", "label": "adaptive"}, - {"id": "max", "label": "max"} - ] - } - } - """.trimIndent() - }, - ) + createChatController { _, paramsJson -> + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + val acceptedThinking = (params["thinkingLevel"] as? JsonPrimitive)?.content ?: "max" + """{"resolved":{"modelProvider":"anthropic","model":"claude-sonnet-5",${thinkingFields(acceptedThinking, "off", "minimal", "low", "medium", "high", "xhigh", "adaptive", "max")}}}""" + } assertTrue(controller.setSessionModelAwait("main", "anthropic/claude-sonnet-5")) @@ -94,44 +65,21 @@ class ChatControllerModelSelectionTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun existingSessionPreservesEffectiveLevelOmittedFromAdvertisedOptions() = runTest { val sentThinkingLevels = mutableListOf() val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - when (method) { - "sessions.list" -> - """ - { - "sessions": [ - { - "key": "main", - "modelProvider": "openai", - "model": "gpt-5.6-luna", - "thinkingLevel": "ultra", - "thinkingLevels": [ - {"id": "off", "label": "off"}, - {"id": "high", "label": "high"}, - {"id": "xhigh", "label": "xhigh"}, - {"id": "max", "label": "max"} - ] - } - ] - } - """.trimIndent() - "chat.send" -> { - val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject - sentThinkingLevels += (params["thinking"] as JsonPrimitive).content - """{"runId":"run-ok","status":"ok"}""" - } - else -> "{}" - } - }, - ) + createScriptedChatController { + respond( + "sessions.list", + """{"sessions":[{"key":"main","modelProvider":"openai","model":"gpt-5.6-luna",${thinkingFields("ultra", "off", "high", "xhigh", "max")}}]}""", + ) + respond("chat.send") { paramsJson -> + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + sentThinkingLevels += (params["thinking"] as JsonPrimitive).content + """{"runId":"run-ok","status":"ok"}""" + } + } controller.refreshSessions() advanceUntilIdle() @@ -161,12 +109,9 @@ class ChatControllerModelSelectionTest { runTest { val recents = mutableListOf() val controller = - ChatController( - scope = this, - json = json, - requestGateway = { _, _ -> error("patch failed") }, + createChatController( recordModelRecent = recents::add, - ) + ) { _, _ -> error("patch failed") } assertFalse(controller.setSessionModelAwait("main", "openai/gpt-5")) @@ -181,15 +126,12 @@ class ChatControllerModelSelectionTest { val requests = mutableListOf() val recents = mutableListOf() val controller = - ChatController( - scope = this, - json = json, - requestGateway = { _, paramsJson -> - requests += paramsJson - "{}" - }, + createChatController( recordModelRecent = recents::add, - ) + ) { _, paramsJson -> + requests += paramsJson + "{}" + } assertTrue(controller.setSessionModelAwait("main", null)) @@ -204,22 +146,18 @@ class ChatControllerModelSelectionTest { val releasePatch = CompletableDeferred() val requests = mutableListOf() val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - requests += method - when (method) { - "sessions.patch" -> { - patchStarted.complete(Unit) - releasePatch.await() - "{}" - } - "chat.send" -> """{"runId":"run-ok","status":"ok"}""" - else -> "{}" + createChatController { method, _ -> + requests += method + when (method) { + "sessions.patch" -> { + patchStarted.complete(Unit) + releasePatch.await() + "{}" } - }, - ) + "chat.send" -> """{"runId":"run-ok","status":"ok"}""" + else -> "{}" + } + } controller.handleGatewayEvent("health", null) controller.setSessionModel("main", "openai/gpt-5") @@ -249,29 +187,20 @@ class ChatControllerModelSelectionTest { runTest { val modelPatchStarted = CompletableDeferred() val releaseModelPatch = CompletableDeferred() - val requests = mutableListOf>() - val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - requests += method to paramsJson - when (method) { - "sessions.patch" -> { - val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject - if ("model" in params) { - modelPatchStarted.complete(Unit) - releaseModelPatch.await() - """{"resolved":{"thinkingLevel":"high","thinkingLevels":[{"id":"off","label":"off"},{"id":"high","label":"high"},{"id":"ultra","label":"ultra"}]}}""" - } else { - """{"resolved":{"thinkingLevel":"ultra"}}""" - } - } - "chat.send" -> """{"runId":"run-ok","status":"ok"}""" - else -> "{}" + val (controller, requests) = + chatControllerTestSetup { + respond("sessions.patch") { paramsJson -> + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + if ("model" in params) { + modelPatchStarted.complete(Unit) + releaseModelPatch.await() + """{"resolved":{"thinkingLevel":"high","thinkingLevels":[{"id":"off","label":"off"},{"id":"high","label":"high"},{"id":"ultra","label":"ultra"}]}}""" + } else { + """{"resolved":{"thinkingLevel":"ultra"}}""" } - }, - ) + } + respond("chat.send", """{"runId":"run-ok","status":"ok"}""") + } controller.handleGatewayEvent("health", null) controller.setSessionModel("main", "openai/gpt-5.6-sol") @@ -307,30 +236,23 @@ class ChatControllerModelSelectionTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun failedThinkingPatchRollsBackToModelAcceptedLevelWithoutSessionRow() = runTest { val modelPatchStarted = CompletableDeferred() val releaseModelPatch = CompletableDeferred() val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - if (method != "sessions.patch") { - "{}" + createScriptedChatController { + respond("sessions.patch") { paramsJson -> + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + if ("model" in params) { + modelPatchStarted.complete(Unit) + releaseModelPatch.await() + """{"resolved":{"thinkingLevel":"high","thinkingLevels":[{"id":"off","label":"off"},{"id":"high","label":"high"},{"id":"ultra","label":"ultra"}]}}""" } else { - val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject - if ("model" in params) { - modelPatchStarted.complete(Unit) - releaseModelPatch.await() - """{"resolved":{"thinkingLevel":"high","thinkingLevels":[{"id":"off","label":"off"},{"id":"high","label":"high"},{"id":"ultra","label":"ultra"}]}}""" - } else { - error("thinking rejected") - } + error("thinking rejected") } - }, - ) + } + } controller.setSessionModel("main", "openai/gpt-5.6-sol") modelPatchStarted.await() @@ -343,26 +265,22 @@ class ChatControllerModelSelectionTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun thinkingRollbackStateIsScopedToGatewayConnection() = runTest { var gatewayScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) val controller = - ChatController( - scope = this, - json = json, + createChatController( cacheScope = { gatewayScope }, - requestGateway = { method, _ -> - when { - method == "sessions.list" -> - """{"sessions":[{"key":"main","thinkingLevel":"off"}]}""" - method == "sessions.patch" && gatewayScope.gatewayId == "gateway-a" -> - """{"resolved":{"thinkingLevel":"medium"}}""" - method == "sessions.patch" -> error("thinking rejected") - else -> "{}" - } - }, - ) + ) { method, _ -> + when { + method == "sessions.list" -> + """{"sessions":[{"key":"main","thinkingLevel":"off"}]}""" + method == "sessions.patch" && gatewayScope.gatewayId == "gateway-a" -> + """{"resolved":{"thinkingLevel":"medium"}}""" + method == "sessions.patch" -> error("thinking rejected") + else -> "{}" + } + } controller.setThinkingLevel("medium") advanceUntilIdle() @@ -388,11 +306,8 @@ class ChatControllerModelSelectionTest { val gatewayScope = ChatCacheScope(gatewayId = " gateway-a ", connectionGeneration = 7) val normalizedScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 7) val controller = - ChatController( - scope = this, - json = json, + createChatController( cacheScope = { gatewayScope }, - requestGateway = { _, _ -> error("unscoped request") }, captureSettingsRequestLease = { scope -> scope ?: error("missing scope") GatewaySession.RequestLease(scope.gatewayId) { _, _, _ -> @@ -400,7 +315,7 @@ class ChatControllerModelSelectionTest { "{}" } }, - ) + ) { _, _ -> error("unscoped request") } assertTrue(controller.setSessionModelAwait("main", "openai/gpt-5.6-sol")) @@ -408,32 +323,25 @@ class ChatControllerModelSelectionTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun staleGatewayThinkingFailureDoesNotReplaceCurrentError() = runTest { val oldPatchStarted = CompletableDeferred() val releaseOldPatch = CompletableDeferred() var gatewayScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) val controller = - ChatController( - scope = this, - json = json, - cacheScope = { gatewayScope }, - requestGateway = { method, paramsJson -> - if (method != "sessions.patch") { - "{}" - } else { - val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject - val level = (params["thinkingLevel"] as? JsonPrimitive)?.content - if (level == "medium") { - oldPatchStarted.complete(Unit) - releaseOldPatch.await() - error("old gateway failure") - } - error("current gateway failure") + createScriptedChatController { + cacheScope = { gatewayScope } + respond("sessions.patch") { paramsJson -> + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + val level = (params["thinkingLevel"] as? JsonPrimitive)?.content + if (level == "medium") { + oldPatchStarted.complete(Unit) + releaseOldPatch.await() + error("old gateway failure") } - }, - ) + error("current gateway failure") + } + } controller.setThinkingLevel("medium") oldPatchStarted.await() @@ -449,31 +357,24 @@ class ChatControllerModelSelectionTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun staleGatewayModelFailureDoesNotReplaceCurrentError() = runTest { val oldPatchStarted = CompletableDeferred() val releaseOldPatch = CompletableDeferred() var gatewayScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) val controller = - ChatController( - scope = this, - json = json, - cacheScope = { gatewayScope }, - requestGateway = { method, paramsJson -> - if (method != "sessions.patch") { - "{}" - } else { - val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject - if ("model" in params) { - oldPatchStarted.complete(Unit) - releaseOldPatch.await() - error("old gateway failure") - } - error("current gateway failure") + createScriptedChatController { + cacheScope = { gatewayScope } + respond("sessions.patch") { paramsJson -> + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + if ("model" in params) { + oldPatchStarted.complete(Unit) + releaseOldPatch.await() + error("old gateway failure") } - }, - ) + error("current gateway failure") + } + } controller.setSessionModel("main", "openai/gpt-old") oldPatchStarted.await() @@ -489,7 +390,6 @@ class ChatControllerModelSelectionTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun queuedMutationDoesNotCrossGatewayConnection() = runTest { val oldModelPatchStarted = CompletableDeferred() @@ -497,27 +397,21 @@ class ChatControllerModelSelectionTest { val patchedThinkingLevels = mutableListOf() var gatewayScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) val controller = - ChatController( - scope = this, - json = json, - cacheScope = { gatewayScope }, - requestGateway = { method, paramsJson -> - if (method != "sessions.patch") { + createScriptedChatController { + cacheScope = { gatewayScope } + respond("sessions.patch") { paramsJson -> + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + if ("model" in params) { + oldModelPatchStarted.complete(Unit) + releaseOldModelPatch.await() "{}" } else { - val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject - if ("model" in params) { - oldModelPatchStarted.complete(Unit) - releaseOldModelPatch.await() - "{}" - } else { - val level = (params["thinkingLevel"] as JsonPrimitive).content - patchedThinkingLevels += level - """{"resolved":{"thinkingLevel":"$level"}}""" - } + val level = (params["thinkingLevel"] as JsonPrimitive).content + patchedThinkingLevels += level + """{"resolved":{"thinkingLevel":"$level"}}""" } - }, - ) + } + } controller.setSessionModel("main", "openai/gpt-old") oldModelPatchStarted.await() @@ -535,27 +429,21 @@ class ChatControllerModelSelectionTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun failedThinkingPatchUsesRefreshedAuthoritativeLevel() = runTest { var sessionLevel = "off" val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - when (method) { - "sessions.list" -> """{"sessions":[{"key":"main","thinkingLevel":"$sessionLevel"}]}""" - "sessions.patch" -> { - val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject - val level = (params["thinkingLevel"] as JsonPrimitive).content - if (level == "max") error("rejected") - """{"resolved":{"thinkingLevel":"$level"}}""" - } - else -> "{}" - } - }, - ) + createScriptedChatController { + respond("sessions.list") { + """{"sessions":[{"key":"main","thinkingLevel":"$sessionLevel"}]}""" + } + respond("sessions.patch") { paramsJson -> + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + val level = (params["thinkingLevel"] as JsonPrimitive).content + if (level == "max") error("rejected") + """{"resolved":{"thinkingLevel":"$level"}}""" + } + } controller.refreshSessions() advanceUntilIdle() @@ -573,7 +461,6 @@ class ChatControllerModelSelectionTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun sessionsRefreshRetriesWhenThinkingPatchOverlapsResponse() = runTest { val firstListStarted = CompletableDeferred() @@ -582,28 +469,21 @@ class ChatControllerModelSelectionTest { val releaseThinkingPatch = CompletableDeferred() var listRequests = 0 val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - when (method) { - "sessions.list" -> { - listRequests += 1 - if (listRequests == 1) { - firstListStarted.complete(Unit) - releaseFirstList.await() - } - """{"sessions":[{"key":"main","thinkingLevel":"high"}]}""" - } - "sessions.patch" -> { - thinkingPatchStarted.complete(Unit) - releaseThinkingPatch.await() - error("rejected") - } - else -> "{}" + createScriptedChatController { + respond("sessions.list") { _ -> + listRequests += 1 + if (listRequests == 1) { + firstListStarted.complete(Unit) + releaseFirstList.await() } - }, - ) + """{"sessions":[{"key":"main","thinkingLevel":"high"}]}""" + } + respond("sessions.patch") { _ -> + thinkingPatchStarted.complete(Unit) + releaseThinkingPatch.await() + error("rejected") + } + } controller.refreshSessions() firstListStarted.await() @@ -623,7 +503,6 @@ class ChatControllerModelSelectionTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun sessionsRefreshDoesNotWaitForSettingsOnPreviousGateway() = runTest { val oldPatchStarted = CompletableDeferred() @@ -631,19 +510,8 @@ class ChatControllerModelSelectionTest { val newListFinished = CompletableDeferred() var gatewayScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) val controller = - ChatController( - scope = this, - json = json, + createChatController( cacheScope = { gatewayScope }, - requestGateway = { method, _ -> - when (method) { - "sessions.list" -> { - newListFinished.complete(Unit) - """{"sessions":[{"key":"main","thinkingLevel":"high"}]}""" - } - else -> "{}" - } - }, requestGatewayForGateway = { gatewayId, method, _ -> if (gatewayId == "gateway-a" && method == "sessions.patch") { oldPatchStarted.complete(Unit) @@ -651,7 +519,15 @@ class ChatControllerModelSelectionTest { } "{}" }, - ) + ) { method, _ -> + when (method) { + "sessions.list" -> { + newListFinished.complete(Unit) + """{"sessions":[{"key":"main","thinkingLevel":"high"}]}""" + } + else -> "{}" + } + } controller.setThinkingLevel("max") oldPatchStarted.await() @@ -669,29 +545,22 @@ class ChatControllerModelSelectionTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun twoFailedQueuedThinkingPatchesWithoutSessionRowRestoreConfirmedLevel() = runTest { val firstPatchStarted = CompletableDeferred() val releaseFirstPatch = CompletableDeferred() val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - if (method != "sessions.patch") { - "{}" - } else { - val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject - val level = (params["thinkingLevel"] as JsonPrimitive).content - if (level == "medium") { - firstPatchStarted.complete(Unit) - releaseFirstPatch.await() - } - error("rejected") + createScriptedChatController { + respond("sessions.patch") { paramsJson -> + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + val level = (params["thinkingLevel"] as JsonPrimitive).content + if (level == "medium") { + firstPatchStarted.complete(Unit) + releaseFirstPatch.await() } - }, - ) + error("rejected") + } + } controller.setThinkingLevel("medium") firstPatchStarted.await() @@ -703,35 +572,24 @@ class ChatControllerModelSelectionTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun failedLatestThinkingPatchRestoresOlderAcceptedOptionsWithoutSessionRow() = runTest { val firstPatchStarted = CompletableDeferred() val releaseFirstPatch = CompletableDeferred() val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - if (method != "sessions.patch") { - "{}" + createScriptedChatController { + respond("sessions.patch") { paramsJson -> + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + val level = (params["thinkingLevel"] as JsonPrimitive).content + if (level == "medium") { + firstPatchStarted.complete(Unit) + releaseFirstPatch.await() + """{"resolved":{${thinkingFields("medium", "off", "medium")}}}""" } else { - val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject - val level = (params["thinkingLevel"] as JsonPrimitive).content - if (level == "medium") { - firstPatchStarted.complete(Unit) - releaseFirstPatch.await() - """ - {"resolved":{"thinkingLevel":"medium","thinkingLevels":[ - {"id":"off","label":"off"},{"id":"medium","label":"medium"} - ]}} - """.trimIndent() - } else { - error("rejected") - } + error("rejected") } - }, - ) + } + } controller.setThinkingLevel("medium") firstPatchStarted.await() @@ -749,31 +607,19 @@ class ChatControllerModelSelectionTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun failedThinkingPatchPreservesGatewayOptionsWithoutSessionRow() = runTest { val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - when (method) { - "sessions.patch" -> { - val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject - if ("model" in params) { - """ - {"resolved":{"thinkingLevel":"off","thinkingLevels":[ - {"id":"off","label":"off"},{"id":"high","label":"high"} - ]}} - """.trimIndent() - } else { - error("rejected") - } - } - else -> "{}" + createScriptedChatController { + respond("sessions.patch") { paramsJson -> + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + if ("model" in params) { + """{"resolved":{${thinkingFields("off", "off", "high")}}}""" + } else { + error("rejected") } - }, - ) + } + } assertTrue(controller.setSessionModelAwait("main", "openai/gpt-5.6-sol")) controller.setThinkingLevel("high") @@ -789,33 +635,20 @@ class ChatControllerModelSelectionTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun modelPatchPreservesAcceptedOptionsWhenResolutionOmitsThem() = runTest { val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - when (method) { - "sessions.list" -> - """ - {"sessions":[{"key":"main","thinkingLevel":"off","thinkingLevels":[ - {"id":"off","label":"off"},{"id":"ultra","label":"ultra"} - ]}]} - """.trimIndent() - "sessions.patch" -> { - val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject - if ("model" in params) { - """{"resolved":{"modelProvider":"openai","model":"gpt-5.6-sol","thinkingLevel":"off"}}""" - } else { - error("rejected") - } - } - else -> "{}" + createScriptedChatController { + respond("sessions.list", """{"sessions":[{"key":"main",${thinkingFields("off", "off", "ultra")}}]}""") + respond("sessions.patch") { paramsJson -> + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + if ("model" in params) { + """{"resolved":{"modelProvider":"openai","model":"gpt-5.6-sol","thinkingLevel":"off"}}""" + } else { + error("rejected") } - }, - ) + } + } controller.refreshSessions() advanceUntilIdle() @@ -833,37 +666,20 @@ class ChatControllerModelSelectionTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun modelPatchUpdatesAcceptedOptionsWhenResolutionOmitsLevel() = runTest { val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - when (method) { - "sessions.list" -> - """ - {"sessions":[{"key":"main","thinkingLevel":"off","thinkingLevels":[ - {"id":"off","label":"off"},{"id":"high","label":"high"} - ]}]} - """.trimIndent() - "sessions.patch" -> { - val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject - if ("model" in params) { - """ - {"resolved":{"modelProvider":"openai","model":"gpt-5.6-sol","thinkingLevels":[ - {"id":"off","label":"off"},{"id":"max","label":"max"} - ]}} - """.trimIndent() - } else { - error("rejected") - } - } - else -> "{}" + createScriptedChatController { + respond("sessions.list", """{"sessions":[{"key":"main",${thinkingFields("off", "off", "high")}}]}""") + respond("sessions.patch") { paramsJson -> + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + if ("model" in params) { + """{"resolved":{"modelProvider":"openai","model":"gpt-5.6-sol",${thinkingFields(null, "off", "max")}}}""" + } else { + error("rejected") } - }, - ) + } + } controller.refreshSessions() advanceUntilIdle() @@ -881,37 +697,20 @@ class ChatControllerModelSelectionTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun modelPatchPreservesAcceptedThinkingWhenResolutionOmitsThinkingMetadata() = runTest { val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - when (method) { - "sessions.list" -> - """ - {"sessions":[{"key":"main","thinkingLevel":"off","thinkingLevels":[ - {"id":"off","label":"off"},{"id":"ultra","label":"ultra"} - ]}]} - """.trimIndent() - "sessions.patch" -> { - val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject - if ("model" in params) { - """{"resolved":{"modelProvider":"openai","model":"gpt-5.6-sol"}}""" - } else { - """ - {"resolved":{"thinkingLevel":"ultra","thinkingLevels":[ - {"id":"off","label":"off"},{"id":"ultra","label":"ultra"} - ]}} - """.trimIndent() - } - } - else -> "{}" + createScriptedChatController { + respond("sessions.list", """{"sessions":[{"key":"main",${thinkingFields("off", "off", "ultra")}}]}""") + respond("sessions.patch") { paramsJson -> + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + if ("model" in params) { + """{"resolved":{"modelProvider":"openai","model":"gpt-5.6-sol"}}""" + } else { + """{"resolved":{${thinkingFields("ultra", "off", "ultra")}}}""" } - }, - ) + } + } controller.refreshSessions() advanceUntilIdle() @@ -935,33 +734,24 @@ class ChatControllerModelSelectionTest { val releaseFirstPatch = CompletableDeferred() val secondPatchStarted = CompletableDeferred() val releaseSecondPatch = CompletableDeferred() - val requests = mutableListOf>() - val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - requests += method to paramsJson - when (method) { - "sessions.patch" -> { - val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject - when ((params["thinkingLevel"] as? JsonPrimitive)?.content) { - "high" -> { - firstPatchStarted.complete(Unit) - releaseFirstPatch.await() - } - "ultra" -> { - secondPatchStarted.complete(Unit) - releaseSecondPatch.await() - } - } - "{}" + val (controller, requests) = + chatControllerTestSetup { + respond("sessions.patch") { paramsJson -> + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + when ((params["thinkingLevel"] as? JsonPrimitive)?.content) { + "high" -> { + firstPatchStarted.complete(Unit) + releaseFirstPatch.await() + } + "ultra" -> { + secondPatchStarted.complete(Unit) + releaseSecondPatch.await() } - "chat.send" -> """{"runId":"run-ok","status":"ok"}""" - else -> "{}" } - }, - ) + "{}" + } + respond("chat.send", """{"runId":"run-ok","status":"ok"}""") + } controller.handleGatewayEvent("health", null) controller.setThinkingLevel("high") @@ -989,33 +779,26 @@ class ChatControllerModelSelectionTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun repeatedThinkingValueStillUsesLatestRequestIdentity() = runTest { val firstPatchStarted = CompletableDeferred() val releaseFirstPatch = CompletableDeferred() var patchIndex = 0 val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - if (method != "sessions.patch") { - "{}" - } else { - patchIndex += 1 - when (patchIndex) { - 1 -> { - firstPatchStarted.complete(Unit) - releaseFirstPatch.await() - """{"resolved":{"thinkingLevel":"medium"}}""" - } - 2 -> """{"resolved":{"thinkingLevel":"ultra"}}""" - else -> """{"resolved":{"thinkingLevel":"max"}}""" + createScriptedChatController { + respond("sessions.patch") { + patchIndex += 1 + when (patchIndex) { + 1 -> { + firstPatchStarted.complete(Unit) + releaseFirstPatch.await() + """{"resolved":{"thinkingLevel":"medium"}}""" } + 2 -> """{"resolved":{"thinkingLevel":"ultra"}}""" + else -> """{"resolved":{"thinkingLevel":"max"}}""" } - }, - ) + } + } controller.setThinkingLevel("high") firstPatchStarted.await() @@ -1035,22 +818,18 @@ class ChatControllerModelSelectionTest { val releasePatch = CompletableDeferred() val requests = mutableListOf() val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - requests += method - when (method) { - "sessions.patch" -> { - patchStarted.complete(Unit) - releasePatch.await() - error("patch failed") - } - "chat.send" -> """{"runId":"run-unexpected","status":"ok"}""" - else -> "{}" + createChatController { method, _ -> + requests += method + when (method) { + "sessions.patch" -> { + patchStarted.complete(Unit) + releasePatch.await() + error("patch failed") } - }, - ) + "chat.send" -> """{"runId":"run-unexpected","status":"ok"}""" + else -> "{}" + } + } controller.handleGatewayEvent("health", null) controller.setSessionModel("main", "openai/gpt-5") @@ -1072,28 +851,20 @@ class ChatControllerModelSelectionTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun staleHistoryDoesNotOverwriteAcceptedModelSelection() = runTest { val historyStarted = CompletableDeferred() val releaseHistory = CompletableDeferred() val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - when (method) { - "chat.history" -> { - historyStarted.complete(Unit) - releaseHistory.await() - """{"messages":[],"sessionInfo":{"key":"main","modelProvider":"anthropic","model":"claude-opus-4"}}""" - } - "sessions.list" -> """{"sessions":[]}""" - "chat.metadata" -> """{"commands":[],"models":[]}""" - else -> "{}" - } - }, - ) + createScriptedChatController { + respond("chat.history") { _ -> + historyStarted.complete(Unit) + releaseHistory.await() + """{"messages":[],"sessionInfo":{"key":"main","modelProvider":"anthropic","model":"claude-opus-4"}}""" + } + respond("sessions.list", """{"sessions":[]}""") + respond("chat.metadata", """{"commands":[],"models":[]}""") + } controller.load("main") historyStarted.await() @@ -1106,49 +877,41 @@ class ChatControllerModelSelectionTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun historyHydratesSelectedModelAndAgentScopedCatalog() = runTest { - val requests = mutableListOf>() - val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - requests += method to paramsJson - when (method) { - "chat.history" -> - """ - { - "sessionId": "session-ops", - "messages": [], - "sessionInfo": { - "key": "agent:ops:main", - "modelProvider": "anthropic", - "model": "claude-opus-4" - } - } - """.trimIndent() - "chat.metadata" -> - """ - { - "commands": [], - "models": [ - { - "id": "claude-opus-4", - "name": "Claude Opus 4", - "provider": "anthropic", - "available": true, - "input": ["text"] - } - ] - } - """.trimIndent() - "sessions.list" -> """{"sessions":[]}""" - else -> "{}" + val (controller, requests) = + chatControllerTestSetup { + respond("chat.history") { paramsJson -> + """ + { + "sessionId": "session-ops", + "messages": [], + "sessionInfo": { + "key": "agent:ops:main", + "modelProvider": "anthropic", + "model": "claude-opus-4" + } } - }, - ) + """.trimIndent() + } + respond("chat.metadata") { paramsJson -> + """ + { + "commands": [], + "models": [ + { + "id": "claude-opus-4", + "name": "Claude Opus 4", + "provider": "anthropic", + "available": true, + "input": ["text"] + } + ] + } + """.trimIndent() + } + respond("sessions.list", """{"sessions":[]}""") + } controller.load("agent:ops:main") advanceUntilIdle() @@ -1165,28 +928,20 @@ class ChatControllerModelSelectionTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun emptyModelCatalogIsRetriedOnNextHealthEvent() = runTest { var metadataRequests = 0 val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - when (method) { - "chat.metadata" -> { - metadataRequests += 1 - if (metadataRequests == 1) { - """{"commands":[{"name":"new","textAliases":["/new"]}],"models":[]}""" - } else { - """{"commands":[{"name":"new","textAliases":["/new"]}],"models":[{"id":"gpt-5","provider":"openai","input":["text"]}]}""" - } - } - else -> "{}" + createScriptedChatController { + respond("chat.metadata") { _ -> + metadataRequests += 1 + if (metadataRequests == 1) { + """{"commands":[{"name":"new","textAliases":["/new"]}],"models":[]}""" + } else { + """{"commands":[{"name":"new","textAliases":["/new"]}],"models":[{"id":"gpt-5","provider":"openai","input":["text"]}]}""" } - }, - ) + } + } controller.handleGatewayEvent("health", null) advanceUntilIdle() @@ -1205,23 +960,16 @@ class ChatControllerModelSelectionTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun validEmptyModelCatalogStopsAfterOneRetry() = runTest { var metadataRequests = 0 val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - if (method == "chat.metadata") { - metadataRequests += 1 - """{"commands":[],"models":[]}""" - } else { - "{}" - } - }, - ) + createScriptedChatController { + respond("chat.metadata") { + metadataRequests += 1 + """{"commands":[],"models":[]}""" + } + } repeat(3) { controller.handleGatewayEvent("health", null) @@ -1233,38 +981,31 @@ class ChatControllerModelSelectionTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun unsupportedReasoningSendsOffWithoutChangingStoredLevelAndRestoresAfterFlip() = runTest { val sentThinkingLevels = mutableListOf() val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - when (method) { - "chat.send" -> { - val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject - sentThinkingLevels += (params["thinking"] as JsonPrimitive).content - """{"runId":"run-${sentThinkingLevels.size}","status":"ok"}""" - } - "chat.history" -> """{"messages":[],"sessionInfo":{"key":"main"}}""" - "sessions.list" -> """{"sessions":[]}""" - // Gating reads the controller-owned agent-scoped catalog hydrated from chat.metadata. - "chat.metadata" -> - """ - { - "commands": [], - "models": [ - {"id": "plain", "name": "plain", "provider": "openai", "available": true, "input": ["text"], "reasoning": false}, - {"id": "reasoning", "name": "reasoning", "provider": "openai", "available": true, "input": ["text"], "reasoning": true} - ] - } - """.trimIndent() - else -> "{}" + createScriptedChatController { + respond("chat.send") { paramsJson -> + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + sentThinkingLevels += (params["thinking"] as JsonPrimitive).content + """{"runId":"run-${sentThinkingLevels.size}","status":"ok"}""" + } + respond("chat.history", """{"messages":[],"sessionInfo":{"key":"main"}}""") + // Gating reads the controller-owned agent-scoped catalog hydrated from chat.metadata. + respond("sessions.list", """{"sessions":[]}""") + respond("chat.metadata") { paramsJson -> + """ + { + "commands": [], + "models": [ + {"id": "plain", "name": "plain", "provider": "openai", "available": true, "input": ["text"], "reasoning": false}, + {"id": "reasoning", "name": "reasoning", "provider": "openai", "available": true, "input": ["text"], "reasoning": true} + ] } - }, - ) + """.trimIndent() + } + } controller.handleGatewayEvent("health", null) controller.load("main") advanceUntilIdle() @@ -1294,57 +1035,39 @@ class ChatControllerModelSelectionTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun advertisedThinkingLevelsOverrideCatalogReasoningFlagForSend() = runTest { val sentThinkingLevels = mutableListOf() val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - when (method) { - "chat.metadata" -> - """ + createScriptedChatController { + respond("chat.metadata") { paramsJson -> + """ + { + "commands": [], + "models": [ { - "commands": [], - "models": [ - { - "id": "reasoner", - "name": "Reasoner", - "provider": "synthetic", - "available": true, - "input": ["text"], - "reasoning": false - } - ] + "id": "reasoner", + "name": "Reasoner", + "provider": "synthetic", + "available": true, + "input": ["text"], + "reasoning": false } - """.trimIndent() - "chat.history" -> """{"messages":[],"sessionInfo":{"key":"main"}}""" - "sessions.list" -> """{"sessions":[]}""" - "sessions.patch" -> - """ - { - "resolved": { - "modelProvider": "synthetic", - "model": "reasoner", - "thinkingLevel": "max", - "thinkingLevels": [ - {"id": "off", "label": "off"}, - {"id": "max", "label": "max"} - ] - } - } - """.trimIndent() - "chat.send" -> { - val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject - sentThinkingLevels += (params["thinking"] as JsonPrimitive).content - """{"runId":"run-ok","status":"ok"}""" - } - else -> "{}" + ] } - }, - ) + """.trimIndent() + } + respond("chat.history", """{"messages":[],"sessionInfo":{"key":"main"}}""") + respond("sessions.list", """{"sessions":[]}""") + respond("sessions.patch") { paramsJson -> + """{"resolved":{"modelProvider":"synthetic","model":"reasoner",${thinkingFields("max", "off", "max")}}}""" + } + respond("chat.send") { paramsJson -> + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + sentThinkingLevels += (params["thinking"] as JsonPrimitive).content + """{"runId":"run-ok","status":"ok"}""" + } + } controller.handleGatewayEvent("health", null) controller.load("main") advanceUntilIdle() @@ -1360,4 +1083,15 @@ class ChatControllerModelSelectionTest { assertEquals(listOf("max"), sentThinkingLevels) } + + private fun thinkingFields( + level: String?, + vararg options: String, + ): String = + listOfNotNull( + level?.let { """"thinkingLevel":"$it"""" }, + options.takeIf { it.isNotEmpty() }?.joinToString(",", "\"thinkingLevels\":[", "]") { + """{"id":"$it","label":"$it"}""" + }, + ).joinToString(",") } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerTerminalAckTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerTerminalAckTest.kt index 62a0e1002d8c..37466596fb5e 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerTerminalAckTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerTerminalAckTest.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.async import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.junit.Assert.assertEquals @@ -18,26 +17,23 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +@OptIn(ExperimentalCoroutinesApi::class) class ChatControllerTerminalAckTest { - private val json = Json { ignoreUnknownKeys = true } + private val json = chatControllerTestJson @Test - @OptIn(ExperimentalCoroutinesApi::class) fun composerOwnerMustMatchBeforeSendAdmission() = runTest { val requestedMethods = mutableListOf() var defaultAgentId: String? = "main" val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - requestedMethods += method - """{"runId":"run-started","status":"started"}""" - }, + createChatController( cacheScope = { ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) }, currentDefaultAgentId = { defaultAgentId }, - ) + ) { method, _ -> + requestedMethods += method + """{"runId":"run-started","status":"started"}""" + } controller.handleGatewayEvent("health", null) val ambiguousOwner = ChatComposerOwner(gatewayStableId = "gateway-a", agentId = "main", sessionKey = "main") assertFalse(controller.canSendForOwner(ambiguousOwner)) @@ -83,7 +79,6 @@ class ChatControllerTerminalAckTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun composerOwnerIsRecheckedAfterPendingSettingsComplete() = runTest { val settingsStarted = CompletableDeferred() @@ -91,26 +86,23 @@ class ChatControllerTerminalAckTest { var defaultAgentId: String? = "main" var sendCount = 0 val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - when (method) { - "sessions.patch" -> { - settingsStarted.complete(Unit) - settingsGate.await() - "{}" - } - "chat.send" -> { - sendCount += 1 - """{"runId":"run-started","status":"started"}""" - } - else -> "{}" - } - }, + createChatController( cacheScope = { ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) }, currentDefaultAgentId = { defaultAgentId }, - ) + ) { method, _ -> + when (method) { + "sessions.patch" -> { + settingsStarted.complete(Unit) + settingsGate.await() + "{}" + } + "chat.send" -> { + sendCount += 1 + """{"runId":"run-started","status":"started"}""" + } + else -> "{}" + } + } controller.prepareMainSessionKey("agent:main:node-test") controller.handleGatewayEvent("health", null) controller.setThinkingLevel("high") @@ -139,27 +131,23 @@ class ChatControllerTerminalAckTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun unjournaledNotEnqueuedSendRemainsRejectedAfterOwnerChange() = runTest { val requestGate = CompletableDeferred() var defaultAgentId: String? = "main" var defaultAgentRevision = 0L val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - if (method == "chat.send") { - requestGate.await() - throw GatewayRequestNotEnqueued("not enqueued") - } - "{}" - }, + createChatController( cacheScope = { ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) }, currentDefaultAgentId = { defaultAgentId }, currentDefaultAgentRevision = { defaultAgentRevision }, - ) + ) { method, _ -> + if (method == "chat.send") { + requestGate.await() + throw GatewayRequestNotEnqueued("not enqueued") + } + "{}" + } controller.prepareMainSessionKey("agent:main:node-test") controller.handleGatewayEvent("health", null) @@ -188,20 +176,14 @@ class ChatControllerTerminalAckTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun terminalTimeoutAckRemovesOptimisticUserEchoAndSurfacesFailedAcceptance() = runTest { var requestedMethod: String? = null val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - requestedMethod = method - """{"runId":"run-timeout","status":"timeout"}""" - }, - currentDefaultAgentId = { "main" }, - ) + createChatController { method, _ -> + requestedMethod = method + """{"runId":"run-timeout","status":"timeout"}""" + } controller.handleGatewayEvent("health", null) val accepted = @@ -219,16 +201,10 @@ class ChatControllerTerminalAckTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun nonTerminalStartedAckRetainsOptimisticUserEchoAndPendingRun() = runTest { val controller = - ChatController( - scope = this, - json = json, - requestGateway = { _, _ -> """{"runId":"run-started","status":"started"}""" }, - currentDefaultAgentId = { "main" }, - ) + createChatController { _, _ -> """{"runId":"run-started","status":"started"}""" } controller.handleGatewayEvent("health", null) val accepted = @@ -245,38 +221,32 @@ class ChatControllerTerminalAckTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun canonicalAckRunIdPreservesClientHistoryIdentity() = runTest { var clientRunId: String? = null val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, paramsJson -> - when (method) { - "chat.send" -> { - clientRunId = - requireNotNull(paramsJson) - .let(json::parseToJsonElement) - .jsonObject["idempotencyKey"] - ?.jsonPrimitive - ?.content - """{"runId":"canonical-run","status":"started"}""" - } - "chat.history" -> - historyResponse( - "session-1", - listOf( - ReplayHistoryMessage("user", "canonical", 1_000, idempotencyKey = "$clientRunId:user"), - ReplayHistoryMessage("assistant", "done", 2_000), - ), - ) - else -> "{}" + createChatController { method, paramsJson -> + when (method) { + "chat.send" -> { + clientRunId = + requireNotNull(paramsJson) + .let(json::parseToJsonElement) + .jsonObject["idempotencyKey"] + ?.jsonPrimitive + ?.content + """{"runId":"canonical-run","status":"started"}""" } - }, - currentDefaultAgentId = { "main" }, - ) + "chat.history" -> + historyResponse( + "session-1", + listOf( + ReplayHistoryMessage("user", "canonical", 1_000, idempotencyKey = "$clientRunId:user"), + ReplayHistoryMessage("assistant", "done", 2_000), + ), + ) + else -> "{}" + } + } controller.handleGatewayEvent("health", null) assertTrue(controller.sendMessageAwaitAcceptance("canonical", "off", emptyList())) @@ -298,32 +268,26 @@ class ChatControllerTerminalAckTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun terminalOkAckClearsOptimisticUserEchoAndRefreshesHistory() = runTest { val requestedMethods = mutableListOf() val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - requestedMethods += method - when (method) { - "chat.send" -> """{"runId":"run-ok","status":"ok"}""" - "chat.history" -> - """ - { - "sessionId": "session-1", - "messages": [ - { "role": "assistant", "content": "cached success reply", "timestamp": 1 } - ] - } - """.trimIndent() - else -> "{}" - } - }, - currentDefaultAgentId = { "main" }, - ) + createChatController { method, _ -> + requestedMethods += method + when (method) { + "chat.send" -> """{"runId":"run-ok","status":"ok"}""" + "chat.history" -> + """ + { + "sessionId": "session-1", + "messages": [ + { "role": "assistant", "content": "cached success reply", "timestamp": 1 } + ] + } + """.trimIndent() + else -> "{}" + } + } controller.handleGatewayEvent("health", null) val accepted = @@ -346,16 +310,10 @@ class ChatControllerTerminalAckTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun terminalErrorAckRemovesOptimisticUserEchoAndSurfacesErrorText() = runTest { val controller = - ChatController( - scope = this, - json = json, - requestGateway = { _, _ -> """{"runId":"run-error","status":"error"}""" }, - currentDefaultAgentId = { "main" }, - ) + createChatController { _, _ -> """{"runId":"run-error","status":"error"}""" } controller.handleGatewayEvent("health", null) val accepted = @@ -372,18 +330,12 @@ class ChatControllerTerminalAckTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun definitiveRpcRejectionRestoresComposerOwnership() = runTest { val controller = - ChatController( - scope = this, - json = json, - requestGateway = { _, _ -> - throw GatewayRequestRejected(GatewaySession.ErrorShape("INVALID_REQUEST", "message rejected")) - }, - currentDefaultAgentId = { "main" }, - ) + createChatController { _, _ -> + throw GatewayRequestRejected(GatewaySession.ErrorShape("INVALID_REQUEST", "message rejected")) + } controller.handleGatewayEvent("health", null) val accepted = controller.sendMessageAwaitAcceptance("rejected", "off", emptyList()) @@ -395,16 +347,10 @@ class ChatControllerTerminalAckTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun requestNotEnqueuedRestoresComposerOwnership() = runTest { val controller = - ChatController( - scope = this, - json = json, - requestGateway = { _, _ -> throw GatewayRequestNotEnqueued("not connected") }, - currentDefaultAgentId = { "main" }, - ) + createChatController { _, _ -> throw GatewayRequestNotEnqueued("not connected") } controller.handleGatewayEvent("health", null) val accepted = controller.sendMessageAwaitAcceptance("never sent", "off", emptyList()) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerTranscriptCacheTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerTranscriptCacheTest.kt index 26379de7a51d..7b27c97b0495 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerTranscriptCacheTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerTranscriptCacheTest.kt @@ -1,21 +1,40 @@ package ai.openclaw.app.chat import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +@OptIn(ExperimentalCoroutinesApi::class) class ChatControllerTranscriptCacheTest { - private val json = Json { ignoreUnknownKeys = true } private val gatewayScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) + private fun CoroutineScope.createCachedController( + cache: ChatTranscriptCache, + cacheScope: () -> ChatCacheScope? = { gatewayScope }, + currentDefaultAgentId: () -> String? = { "main" }, + currentDefaultAgentRevision: () -> Long = { 0L }, + onSessionDeleted: (ChatSessionDeletion) -> Unit = {}, + onOfflineDefaultAgentRestored: (String) -> Unit = {}, + requestGateway: suspend (method: String, paramsJson: String?) -> String, + ): ChatController = + createChatController( + transcriptCache = cache, + cacheScope = cacheScope, + currentDefaultAgentId = currentDefaultAgentId, + currentDefaultAgentRevision = currentDefaultAgentRevision, + onSessionDeleted = onSessionDeleted, + onOfflineDefaultAgentRestored = onOfflineDefaultAgentRestored, + requestGateway = requestGateway, + ) + private data class TranscriptKey( val gatewayId: String, val agentId: String, @@ -126,7 +145,6 @@ class ChatControllerTranscriptCacheTest { ) @Test - @OptIn(ExperimentalCoroutinesApi::class) fun offlineColdOpenShowsCachedTranscriptAndSessionsAndKeepsSendBlocked() = runTest { val cache = FakeTranscriptCache() @@ -134,14 +152,7 @@ class ChatControllerTranscriptCacheTest { listOf(cachedMessage("cached hello"), cachedMessage("cached reply")) cache.sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = 5, displayName = "Main")) val controller = - ChatController( - scope = this, - json = json, - requestGateway = { _, _ -> throw IllegalStateException("offline") }, - transcriptCache = cache, - cacheScope = { gatewayScope }, - currentDefaultAgentId = { "main" }, - ) + createCachedController(cache) { _, _ -> throw IllegalStateException("offline") } controller.load("main") advanceUntilIdle() @@ -161,7 +172,6 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun delayedCachedGlobalDigestIsScopedToTheRequestedOwner() = runTest { val cache = FakeTranscriptCache() @@ -191,14 +201,10 @@ class ChatControllerTranscriptCacheTest { } } val controller = - ChatController( - scope = this, - json = json, - requestGateway = { _, _ -> throw IllegalStateException("offline") }, - transcriptCache = cache, - cacheScope = { gatewayScope }, + createCachedController( + cache, currentDefaultAgentId = { "work" }, - ) + ) { _, _ -> throw IllegalStateException("offline") } controller.load("global", ownerAgentId = "work") loadStarted.await() @@ -215,24 +221,19 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun offlineCachedOwnerRebuildsCanonicalMainSessionBeforeComposerSend() = runTest { val cache = FakeTranscriptCache() cache.lastDefaultAgents["gateway-a"] = "work" lateinit var controller: ChatController controller = - ChatController( - scope = this, - json = json, - requestGateway = { _, _ -> throw IllegalStateException("offline") }, - transcriptCache = cache, - cacheScope = { gatewayScope }, + createCachedController( + cache, currentDefaultAgentId = { null }, onOfflineDefaultAgentRestored = { agentId -> controller.applyMainSessionKey("agent:$agentId:node-test") }, - ) + ) { _, _ -> throw IllegalStateException("offline") } controller.load("main") advanceUntilIdle() @@ -243,26 +244,18 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun restoredPendingRunKeepsCachedTranscriptVisible() = runTest { val cache = FakeTranscriptCache() cache.transcripts[TranscriptKey("gateway-a", "main", "main")] = listOf(cachedMessage("cached history")) val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - when (method) { - "chat.send" -> """{"runId":"run-pending"}""" - "health" -> "{}" - else -> throw IllegalStateException("offline") - } - }, - transcriptCache = cache, - cacheScope = { gatewayScope }, - currentDefaultAgentId = { "main" }, - ) + createCachedController(cache) { method, _ -> + when (method) { + "chat.send" -> """{"runId":"run-pending"}""" + "health" -> "{}" + else -> throw IllegalStateException("offline") + } + } controller.load("main") runCurrent() @@ -282,7 +275,6 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun cachedTranscriptEmitsFirstThenLiveHistoryReplacesWholesale() = runTest { val cache = FakeTranscriptCache() @@ -293,30 +285,23 @@ class ChatControllerTranscriptCacheTest { ) val historyGate = CompletableDeferred() val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - when (method) { - "chat.history" -> { - historyGate.await() - """ - { - "sessionId": "session-1", - "messages": [ - { "role": "user", "content": "cached hello", "timestamp": 10 }, - { "role": "assistant", "content": "fresh reply", "timestamp": 20 } - ] - } - """.trimIndent() + createCachedController(cache) { method, _ -> + when (method) { + "chat.history" -> { + historyGate.await() + """ + { + "sessionId": "session-1", + "messages": [ + { "role": "user", "content": "cached hello", "timestamp": 10 }, + { "role": "assistant", "content": "fresh reply", "timestamp": 20 } + ] } - else -> "{}" + """.trimIndent() } - }, - transcriptCache = cache, - cacheScope = { gatewayScope }, - currentDefaultAgentId = { "main" }, - ) + else -> "{}" + } + } controller.load("main") runCurrent() @@ -358,20 +343,12 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun switchSessionOfflineShowsCachedTranscriptForThatSession() = runTest { val cache = FakeTranscriptCache() cache.transcripts[TranscriptKey("gateway-a", "other", "agent:other:main")] = listOf(cachedMessage("other session text")) val controller = - ChatController( - scope = this, - json = json, - requestGateway = { _, _ -> throw IllegalStateException("offline") }, - transcriptCache = cache, - cacheScope = { gatewayScope }, - currentDefaultAgentId = { "main" }, - ) + createCachedController(cache) { _, _ -> throw IllegalStateException("offline") } controller.load("main") advanceUntilIdle() assertEquals(emptyList(), controller.messages.value) @@ -387,21 +364,15 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun sessionDeleteEventPurgesCachedSession() = runTest { val cache = FakeTranscriptCache() val deletions = mutableListOf() val controller = - ChatController( - scope = this, - json = json, - requestGateway = { _, _ -> "{}" }, - transcriptCache = cache, - cacheScope = { gatewayScope }, - currentDefaultAgentId = { "main" }, + createCachedController( + cache, onSessionDeleted = deletions::add, - ) + ) { _, _ -> "{}" } controller.handleGatewayEvent( "sessions.changed", @@ -417,23 +388,18 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun unscopedDeleteEventDoesNotGuessACacheOwner() = runTest { val cache = FakeTranscriptCache() var sessionListRequests = 0 val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - if (method == "sessions.list") sessionListRequests += 1 - if (method == "sessions.list") """{"sessions":[]}""" else "{}" - }, - transcriptCache = cache, - cacheScope = { gatewayScope }, + createCachedController( + cache, currentDefaultAgentId = { "new-default" }, - ) + ) { method, _ -> + if (method == "sessions.list") sessionListRequests += 1 + if (method == "sessions.list") """{"sessions":[]}""" else "{}" + } controller.handleGatewayEvent( "sessions.changed", @@ -446,26 +412,22 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun ownerlessDeleteEventFallsBackAfterCurrentOwnersRefreshConfirmsRemoval() = runTest { var deleted = false val deletions = mutableListOf() val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - if (method == "sessions.list") { - if (deleted) """{"sessions":[]}""" else """{"sessions":[{"key":"custom"}]}""" - } else { - "{}" - } - }, + createChatController( cacheScope = { gatewayScope }, currentDefaultAgentId = { "owner-a" }, onSessionDeleted = deletions::add, - ) + ) { method, _ -> + if (method == "sessions.list") { + if (deleted) """{"sessions":[]}""" else """{"sessions":[{"key":"custom"}]}""" + } else { + "{}" + } + } controller.load("custom", ownerAgentId = "owner-a") advanceUntilIdle() assertEquals("custom", controller.sessionKey.value) @@ -485,7 +447,6 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun ownerlessDeleteProofStaysBoundToCapturedOwnerAcrossAgentSwitch() = runTest { val cache = FakeTranscriptCache() @@ -493,27 +454,23 @@ class ChatControllerTranscriptCacheTest { val releaseProof = CompletableDeferred() var deleting = false val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, params -> - if (method == "sessions.list") { - val ownerA = params.orEmpty().contains("\"agentId\":\"owner-a\"") - if (deleting && ownerA) { - proofStarted.complete(Unit) - releaseProof.await() - """{"sessions":[]}""" - } else { - """{"sessions":[{"key":"custom"}]}""" - } - } else { - "{}" - } - }, - transcriptCache = cache, - cacheScope = { gatewayScope }, + createCachedController( + cache, currentDefaultAgentId = { "owner-a" }, - ) + ) { method, params -> + if (method == "sessions.list") { + val ownerA = params.orEmpty().contains("\"agentId\":\"owner-a\"") + if (deleting && ownerA) { + proofStarted.complete(Unit) + releaseProof.await() + """{"sessions":[]}""" + } else { + """{"sessions":[{"key":"custom"}]}""" + } + } else { + "{}" + } + } controller.load("custom", ownerAgentId = "owner-a") advanceUntilIdle() @@ -535,30 +492,25 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun overlappingOwnerlessDeletesReconcileEveryCapturedKey() = runTest { val cache = FakeTranscriptCache() val deletedKeys = mutableSetOf() val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - if (method == "sessions.list") { - val sessions = - listOf("custom-a", "custom-b") - .filterNot(deletedKeys::contains) - .joinToString(",") { key -> """{"key":"$key"}""" } - """{"sessions":[$sessions]}""" - } else { - "{}" - } - }, - transcriptCache = cache, - cacheScope = { gatewayScope }, + createCachedController( + cache, currentDefaultAgentId = { "owner-a" }, - ) + ) { method, _ -> + if (method == "sessions.list") { + val sessions = + listOf("custom-a", "custom-b") + .filterNot(deletedKeys::contains) + .joinToString(",") { key -> """{"key":"$key"}""" } + """{"sessions":[$sessions]}""" + } else { + "{}" + } + } controller.refreshSessions() advanceUntilIdle() @@ -584,26 +536,21 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun truncatedOwnerlessDeleteProofPreservesLocalState() = runTest { val cache = FakeTranscriptCache() var deleting = false val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - when { - method != "sessions.list" -> "{}" - deleting -> """{"sessions":[],"hasMore":true}""" - else -> """{"sessions":[{"key":"custom"}]}""" - } - }, - transcriptCache = cache, - cacheScope = { gatewayScope }, + createCachedController( + cache, currentDefaultAgentId = { "owner-a" }, - ) + ) { method, _ -> + when { + method != "sessions.list" -> "{}" + deleting -> """{"sessions":[],"hasMore":true}""" + else -> """{"sessions":[{"key":"custom"}]}""" + } + } controller.refreshSessions() advanceUntilIdle() @@ -619,21 +566,16 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun deleteEventForAnotherOwnerDoesNotMutateTheVisibleSessionList() = runTest { val cache = FakeTranscriptCache() val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - if (method == "sessions.list") """{"sessions":[{"key":"custom"}]}""" else "{}" - }, - transcriptCache = cache, - cacheScope = { gatewayScope }, + createCachedController( + cache, currentDefaultAgentId = { "owner-b" }, - ) + ) { method, _ -> + if (method == "sessions.list") """{"sessions":[{"key":"custom"}]}""" else "{}" + } controller.refreshSessions() advanceUntilIdle() @@ -648,24 +590,20 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun sessionUpdatesStayBoundToTheVisibleOwnerAndRefreshAmbiguousEvents() = runTest { var sessionListRequests = 0 val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - if (method == "sessions.list") { - sessionListRequests += 1 - """{"sessions":[{"key":"custom","label":"Original"}]}""" - } else { - "{}" - } - }, + createChatController( currentDefaultAgentId = { "owner-a" }, - ) + ) { method, _ -> + if (method == "sessions.list") { + sessionListRequests += 1 + """{"sessions":[{"key":"custom","label":"Original"}]}""" + } else { + "{}" + } + } controller.refreshSessions() advanceUntilIdle() @@ -700,28 +638,23 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun requestedUnscopedDeleteCarriesAndPurgesItsCapturedOwner() = runTest { val cache = FakeTranscriptCache() var deleteParams = "" var defaultAgentId = "owner-a" val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, params -> - if (method == "sessions.delete") deleteParams = params.orEmpty() - when (method) { - "sessions.list" -> """{"sessions":[{"key":"custom"}]}""" - "sessions.delete" -> """{"deleted":true}""" - else -> "{}" - } - }, - transcriptCache = cache, - cacheScope = { gatewayScope }, + createCachedController( + cache, currentDefaultAgentId = { defaultAgentId }, - ) + ) { method, params -> + if (method == "sessions.delete") deleteParams = params.orEmpty() + when (method) { + "sessions.list" -> """{"sessions":[{"key":"custom"}]}""" + "sessions.delete" -> """{"deleted":true}""" + else -> "{}" + } + } controller.refreshSessions() advanceUntilIdle() @@ -738,30 +671,26 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun openingUnscopedSessionRetainsTheRenderedOwnerAfterDefaultChanges() = runTest { var defaultAgentId = "owner-a" var defaultAgentRevision = 1L val historyOwners = mutableListOf() val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, params -> - when (method) { - "sessions.list" -> """{"sessions":[{"key":"custom"}]}""" - "chat.history" -> { - historyOwners += if (params.orEmpty().contains("\"agentId\":\"owner-a\"")) "owner-a" else "owner-b" - """{"sessionId":"custom-id","messages":[]}""" - } - else -> "{}" - } - }, + createChatController( cacheScope = { gatewayScope }, currentDefaultAgentId = { defaultAgentId }, currentDefaultAgentRevision = { defaultAgentRevision }, - ) + ) { method, params -> + when (method) { + "sessions.list" -> """{"sessions":[{"key":"custom"}]}""" + "chat.history" -> { + historyOwners += if (params.orEmpty().contains("\"agentId\":\"owner-a\"")) "owner-a" else "owner-b" + """{"sessionId":"custom-id","messages":[]}""" + } + else -> "{}" + } + } controller.refreshSessions() advanceUntilIdle() @@ -783,7 +712,6 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun oldGatewayDeleteResponseDoesNotRemoveTheCurrentGatewayRow() = runTest { val cache = FakeTranscriptCache() @@ -792,24 +720,21 @@ class ChatControllerTranscriptCacheTest { var currentScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) var defaultAgentId = "owner-a" val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - when (method) { - "sessions.list" -> """{"sessions":[{"key":"custom"}]}""" - "sessions.delete" -> { - deleteStarted.complete(Unit) - deleteGate.await() - """{"deleted":true}""" - } - else -> "{}" - } - }, - transcriptCache = cache, + createCachedController( + cache, cacheScope = { currentScope }, currentDefaultAgentId = { defaultAgentId }, - ) + ) { method, _ -> + when (method) { + "sessions.list" -> """{"sessions":[{"key":"custom"}]}""" + "sessions.delete" -> { + deleteStarted.complete(Unit) + deleteGate.await() + """{"deleted":true}""" + } + else -> "{}" + } + } controller.refreshSessions() advanceUntilIdle() @@ -844,21 +769,16 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun unsuccessfulDeleteResponseKeepsTheOfflineCopy() = runTest { val cache = FakeTranscriptCache() val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - if (method == "sessions.delete") """{"deleted":false}""" else """{"sessions":[]}""" - }, - transcriptCache = cache, - cacheScope = { gatewayScope }, + createCachedController( + cache, currentDefaultAgentId = { "owner-a" }, - ) + ) { method, _ -> + if (method == "sessions.delete") """{"deleted":false}""" else """{"sessions":[]}""" + } assertEquals(null, controller.deleteSession("custom", ownerAgentId = "owner-a")) advanceUntilIdle() @@ -867,27 +787,19 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun liveSessionListIsWrittenThroughToCache() = runTest { val cache = FakeTranscriptCache() var sessionListParams = "" val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, params -> - if (method == "sessions.list") sessionListParams = params.orEmpty() - when (method) { - "sessions.list" -> """{"sessions":[{"key":"main","updatedAt":7,"displayName":"Main"}]}""" - "chat.history" -> """{"sessionId":"session-1","messages":[]}""" - else -> "{}" - } - }, - transcriptCache = cache, - cacheScope = { gatewayScope }, - currentDefaultAgentId = { "main" }, - ) + createCachedController(cache) { method, params -> + if (method == "sessions.list") sessionListParams = params.orEmpty() + when (method) { + "sessions.list" -> """{"sessions":[{"key":"main","updatedAt":7,"displayName":"Main"}]}""" + "chat.history" -> """{"sessionId":"session-1","messages":[]}""" + else -> "{}" + } + } controller.load("main") advanceUntilIdle() @@ -907,34 +819,27 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun sessionListParsesGroupingAndUnreadMetadata() = runTest { val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - when (method) { - "sessions.list" -> - """ - { - "sessions": [{ - "key": "main", - "label": "Daily", - "category": "Work", - "pinned": true, - "archived": false, - "unread": true, - "lastReadAt": 10, - "lastActivityAt": 20 - }] - } - """.trimIndent() - else -> "{}" + createScriptedChatController { + respond("sessions.list") { _ -> + """ + { + "sessions": [{ + "key": "main", + "label": "Daily", + "category": "Work", + "pinned": true, + "archived": false, + "unread": true, + "lastReadAt": 10, + "lastActivityAt": 20 + }] } - }, - ) + """.trimIndent() + } + } controller.refreshSessions() advanceUntilIdle() @@ -950,21 +855,12 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun partialSessionChangedEventPreservesExistingMetadata() = runTest { val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - when (method) { - "sessions.list" -> - """{"sessions":[{"key":"main","label":"Daily","category":"Work","pinned":true,"unread":true}]}""" - else -> "{}" - } - }, - ) + createScriptedChatController { + respond("sessions.list", """{"sessions":[{"key":"main","label":"Daily","category":"Work","pinned":true,"unread":true}]}""") + } controller.refreshSessions() advanceUntilIdle() @@ -982,26 +878,18 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun truncatedSessionListRetainsActiveDeepTranscript() = runTest { val cache = FakeTranscriptCache() val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - when (method) { - "sessions.list" -> - """{"totalCount":2,"hasMore":true,"sessions":[{"key":"main","updatedAt":7}]}""" - "chat.history" -> """{"sessionId":"session-1","messages":[]}""" - else -> "{}" - } - }, - transcriptCache = cache, - cacheScope = { gatewayScope }, - currentDefaultAgentId = { "main" }, - ) + createCachedController(cache) { method, _ -> + when (method) { + "sessions.list" -> + """{"totalCount":2,"hasMore":true,"sessions":[{"key":"main","updatedAt":7}]}""" + "chat.history" -> """{"sessionId":"session-1","messages":[]}""" + else -> "{}" + } + } controller.load("deep-session") advanceUntilIdle() @@ -1010,7 +898,6 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun completeSessionListRetainsActiveTranscriptBeyondLocalCacheWindow() = runTest { val cache = FakeTranscriptCache() @@ -1019,21 +906,14 @@ class ChatControllerTranscriptCacheTest { """{"key":"session-$index","updatedAt":${100 - index}}""" } val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - when (method) { - "sessions.list" -> - """{"totalCount":60,"hasMore":false,"sessions":[$sessions]}""" - "chat.history" -> """{"sessionId":"session-55","messages":[]}""" - else -> "{}" - } - }, - transcriptCache = cache, - cacheScope = { gatewayScope }, - currentDefaultAgentId = { "main" }, - ) + createCachedController(cache) { method, _ -> + when (method) { + "sessions.list" -> + """{"totalCount":60,"hasMore":false,"sessions":[$sessions]}""" + "chat.history" -> """{"sessionId":"session-55","messages":[]}""" + else -> "{}" + } + } controller.load("session-55") advanceUntilIdle() @@ -1042,28 +922,23 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun oldGatewayHistoryResponseIsNeitherAppliedNorCachedAfterScopeChange() = runTest { val cache = FakeTranscriptCache() val historyGate = CompletableDeferred() var currentScope = gatewayScope val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - if (method == "chat.history") { - historyGate.await() - """{"sessionId":"old","messages":[{"role":"assistant","content":"old gateway"}]}""" - } else { - "{}" - } - }, - transcriptCache = cache, + createCachedController( + cache, cacheScope = { currentScope }, - currentDefaultAgentId = { "main" }, - ) + ) { method, _ -> + if (method == "chat.history") { + historyGate.await() + """{"sessionId":"old","messages":[{"role":"assistant","content":"old gateway"}]}""" + } else { + "{}" + } + } controller.load("main") runCurrent() @@ -1079,28 +954,23 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun oldGatewaySessionListIsNeitherAppliedNorCachedAfterScopeChange() = runTest { val cache = FakeTranscriptCache() val sessionsGate = CompletableDeferred() var currentScope = gatewayScope val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, _ -> - if (method == "sessions.list") { - sessionsGate.await() - """{"sessions":[{"key":"old-gateway-session"}]}""" - } else { - "{}" - } - }, - transcriptCache = cache, + createCachedController( + cache, cacheScope = { currentScope }, - currentDefaultAgentId = { "main" }, - ) + ) { method, _ -> + if (method == "sessions.list") { + sessionsGate.await() + """{"sessions":[{"key":"old-gateway-session"}]}""" + } else { + "{}" + } + } controller.refreshSessions() runCurrent() @@ -1113,7 +983,6 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun switchingGatewayScopeIsolatesCachedTranscriptAndSessionsThenRestoresThem() = runTest { val cache = FakeTranscriptCache() @@ -1122,14 +991,10 @@ class ChatControllerTranscriptCacheTest { cache.sessionsByOwner["gateway-b" to "main"] = emptyList() var currentScope = ChatCacheScope(gatewayId = "gateway-a", connectionGeneration = 1) val controller = - ChatController( - scope = this, - json = json, - requestGateway = { _, _ -> throw IllegalStateException("offline") }, - transcriptCache = cache, + createCachedController( + cache, cacheScope = { currentScope }, - currentDefaultAgentId = { "main" }, - ) + ) { _, _ -> throw IllegalStateException("offline") } controller.load("main") advanceUntilIdle() @@ -1152,22 +1017,18 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun unscopedHistoryWaitsForAProvableDefaultOwner() = runTest { var requestCount = 0 val controller = - ChatController( - scope = this, - json = json, - requestGateway = { _, _ -> - requestCount += 1 - "{}" - }, + createChatController( transcriptCache = FakeTranscriptCache(), cacheScope = { gatewayScope }, currentDefaultAgentId = { null }, - ) + ) { _, _ -> + requestCount += 1 + "{}" + } controller.load("custom") advanceUntilIdle() @@ -1179,7 +1040,6 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun offlineUnscopedHistoryUsesTheLastVerifiedGatewayOwner() = runTest { val cache = FakeTranscriptCache() @@ -1188,14 +1048,10 @@ class ChatControllerTranscriptCacheTest { cache.sessionsByOwner["gateway-a" to "agent-a"] = listOf(ChatSessionEntry(key = "custom", updatedAtMs = 1, displayName = "Offline custom")) val controller = - ChatController( - scope = this, - json = json, - requestGateway = { _, _ -> error("offline") }, - transcriptCache = cache, - cacheScope = { gatewayScope }, + createCachedController( + cache, currentDefaultAgentId = { null }, - ) + ) { _, _ -> error("offline") } controller.load("custom") advanceUntilIdle() @@ -1207,7 +1063,6 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun defaultOwnerChangeClearsAndReloadsActiveUnscopedHistory() = runTest { var defaultAgentId: String? = "agent-a" @@ -1215,28 +1070,24 @@ class ChatControllerTranscriptCacheTest { val requestedOwners = mutableListOf() val cache = FakeTranscriptCache() val controller = - ChatController( - scope = this, - json = json, - requestGateway = { method, params -> - when (method) { - "chat.history" -> { - val owner = if (params.orEmpty().contains("\"agentId\":\"agent-a\"")) "agent-a" else "agent-b" - requestedOwners += owner - """{"sessionId":"$owner","messages":[{"role":"assistant","content":"$owner history"}]}""" - } - "sessions.list" -> { - val owner = defaultAgentId ?: "unknown" - """{"sessions":[{"key":"custom","displayName":"$owner title","updatedAt":1}]}""" - } - else -> "{}" - } - }, - transcriptCache = cache, - cacheScope = { gatewayScope }, + createCachedController( + cache, currentDefaultAgentId = { defaultAgentId }, currentDefaultAgentRevision = { defaultAgentRevision }, - ) + ) { method, params -> + when (method) { + "chat.history" -> { + val owner = if (params.orEmpty().contains("\"agentId\":\"agent-a\"")) "agent-a" else "agent-b" + requestedOwners += owner + """{"sessionId":"$owner","messages":[{"role":"assistant","content":"$owner history"}]}""" + } + "sessions.list" -> { + val owner = defaultAgentId ?: "unknown" + """{"sessions":[{"key":"custom","displayName":"$owner title","updatedAt":1}]}""" + } + else -> "{}" + } + } controller.load("custom") advanceUntilIdle() @@ -1270,7 +1121,6 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun latestDefaultOwnerWinsWhenThePreviousCacheWriteFinishesLate() = runTest { val cache = FakeTranscriptCache() @@ -1285,15 +1135,11 @@ class ChatControllerTranscriptCacheTest { var defaultAgentId: String? = "agent-a" var defaultAgentRevision = 1L val controller = - ChatController( - scope = this, - json = json, - requestGateway = { _, _ -> "{}" }, - transcriptCache = cache, - cacheScope = { gatewayScope }, + createCachedController( + cache, currentDefaultAgentId = { defaultAgentId }, currentDefaultAgentRevision = { defaultAgentRevision }, - ) + ) { _, _ -> "{}" } controller.onDefaultAgentChanged("agent-a") runCurrent() @@ -1309,7 +1155,6 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun gatewayCachePurgeDeletesAnInFlightDefaultOwnerWriteAndInvalidatesQueuedWrites() = runTest { val cache = FakeTranscriptCache() @@ -1322,13 +1167,7 @@ class ChatControllerTranscriptCacheTest { } } val controller = - ChatController( - scope = this, - json = json, - requestGateway = { _, _ -> "{}" }, - transcriptCache = cache, - cacheScope = { gatewayScope }, - ) + createCachedController(cache) { _, _ -> "{}" } controller.onDefaultAgentChanged("agent-a") runCurrent() @@ -1346,7 +1185,6 @@ class ChatControllerTranscriptCacheTest { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun liveDefaultOwnerWinsWhenPersistedOwnerLoadFinishesLate() = runTest { val cache = FakeTranscriptCache() @@ -1360,15 +1198,11 @@ class ChatControllerTranscriptCacheTest { var defaultAgentId: String? = null var defaultAgentRevision = 1L val controller = - ChatController( - scope = this, - json = json, - requestGateway = { _, _ -> "{}" }, - transcriptCache = cache, - cacheScope = { gatewayScope }, + createCachedController( + cache, currentDefaultAgentId = { defaultAgentId }, currentDefaultAgentRevision = { defaultAgentRevision }, - ) + ) { _, _ -> "{}" } controller.load("custom") runCurrent() diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatReplayHarness.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatReplayHarness.kt index 6984ad3c6b8c..7d9158fb4807 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatReplayHarness.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatReplayHarness.kt @@ -1,5 +1,7 @@ package ai.openclaw.app.chat +import ai.openclaw.app.gateway.GatewaySession +import kotlinx.coroutines.CoroutineScope import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonPrimitive @@ -7,6 +9,96 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +internal val chatControllerTestJson = Json { ignoreUnknownKeys = true } + +internal fun CoroutineScope.createChatController( + requestGatewayForGateway: (suspend (gatewayId: String, method: String, paramsJson: String?) -> String)? = null, + captureSettingsRequestLease: ((gatewayScope: ChatCacheScope?) -> GatewaySession.RequestLease?)? = null, + transcriptCache: ChatTranscriptCache? = null, + cacheScope: () -> ChatCacheScope? = { null }, + currentDefaultAgentId: () -> String? = { "main" }, + currentDefaultAgentRevision: () -> Long = { 0L }, + recordModelRecent: (String) -> Unit = {}, + onSessionDeleted: (ChatSessionDeletion) -> Unit = {}, + onOfflineDefaultAgentRestored: (String) -> Unit = {}, + requestGateway: suspend (method: String, paramsJson: String?) -> String = { _, _ -> "{}" }, +): ChatController { + val scopedRequest = + requestGatewayForGateway ?: { _, method, paramsJson -> requestGateway(method, paramsJson) } + val settingsLease = + captureSettingsRequestLease ?: { gatewayScope -> + GatewaySession.RequestLease(endpointStableId = gatewayScope?.gatewayId.orEmpty()) { method, paramsJson, _ -> + if (gatewayScope == null) { + requestGateway(method, paramsJson) + } else { + scopedRequest(gatewayScope.gatewayId, method, paramsJson) + } + } + } + return ChatController( + scope = this, + json = chatControllerTestJson, + requestGateway = requestGateway, + requestGatewayForGateway = scopedRequest, + captureSettingsRequestLease = settingsLease, + transcriptCache = transcriptCache, + cacheScope = cacheScope, + currentDefaultAgentId = currentDefaultAgentId, + currentDefaultAgentRevision = currentDefaultAgentRevision, + recordModelRecent = recordModelRecent, + onSessionDeleted = onSessionDeleted, + onOfflineDefaultAgentRestored = onOfflineDefaultAgentRestored, + ) +} + +internal class ChatControllerTestSetup( + private val scope: CoroutineScope, +) { + val requests = mutableListOf>() + var cacheScope: () -> ChatCacheScope? = { null } + var recordModelRecent: (String) -> Unit = {} + + private val handlers = mutableMapOf String>() + + fun respond( + method: String, + responseJson: String, + ) { + handlers[method] = { responseJson } + } + + fun respond( + method: String, + handler: suspend (paramsJson: String?) -> String, + ) { + handlers[method] = handler + } + + val controller: ChatController by lazy { + scope.createChatController( + cacheScope = cacheScope, + recordModelRecent = recordModelRecent, + requestGateway = { method, paramsJson -> + requests += method to paramsJson + // Unscripted methods preserve the original controller-test empty-object fallback. + handlers[method]?.invoke(paramsJson) ?: "{}" + }, + ) + } + + operator fun component1(): ChatController = controller + + operator fun component2(): MutableList> = requests +} + +internal fun CoroutineScope.chatControllerTestSetup( + configure: ChatControllerTestSetup.() -> Unit, +): ChatControllerTestSetup = ChatControllerTestSetup(this).apply(configure) + +internal fun CoroutineScope.createScriptedChatController( + configure: ChatControllerTestSetup.() -> Unit, +): ChatController = chatControllerTestSetup(configure).controller + /** * Scripted gateway responder for deterministic chat replay tests. *