refactor(android): consolidate chat controller test setup (#117862)

This commit is contained in:
Peter Steinberger
2026-08-01 23:06:51 -07:00
committed by GitHub
parent b9ed358f93
commit 4e4959b283
5 changed files with 961 additions and 1576 deletions

View File

@@ -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<Pair<String, String?>>()
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<Pair<String, String?>>()
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<String>()
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<Pair<String, String?>>()
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<Pair<String, String?>>()
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<Pair<String, String?>>()
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<Pair<String, String?>>()
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<Pair<String, String?>>()
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<Pair<String, String?>>()
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<Pair<String, String?>>()
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<Pair<String, String?>>()
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<Pair<String, String?>>()
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<Pair<String, String?>>()
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<Pair<String, String?>>()
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<Pair<String, String?>>()
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<Pair<String, String?>>()
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<Pair<String, String?>>()
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<Pair<String, String?>>()
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<Pair<String, String?>>()
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<Pair<String, String?>>()
val createEntered = CompletableDeferred<Unit>()
val releaseCreate = CompletableDeferred<Unit>()
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<Pair<String, String?>>()
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}]}"""
}
}

View File

@@ -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<String>()
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<Unit>()
@@ -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<Unit>()
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<String>()
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())

View File

@@ -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<Pair<String, String?>>()
var cacheScope: () -> ChatCacheScope? = { null }
var recordModelRecent: (String) -> Unit = {}
private val handlers = mutableMapOf<String, suspend (String?) -> 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<Pair<String, String?>> = 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.
*