Merge branch 'main' into fix/tts-tool-no-channel-hang

This commit is contained in:
Hiago Silva 2026-03-16 10:14:17 -03:00 committed by GitHub
commit 4b9aef93fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 155 additions and 21 deletions

View File

@ -265,7 +265,7 @@ class ChatController(
}
val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""")
val history = parseHistory(historyJson, sessionKey = key)
val history = parseHistory(historyJson, sessionKey = key, previousMessages = _messages.value)
_messages.value = history.messages
_sessionId.value = history.sessionId
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
@ -336,7 +336,7 @@ class ChatController(
try {
val historyJson =
session.request("chat.history", """{"sessionKey":"${_sessionKey.value}"}""")
val history = parseHistory(historyJson, sessionKey = _sessionKey.value)
val history = parseHistory(historyJson, sessionKey = _sessionKey.value, previousMessages = _messages.value)
_messages.value = history.messages
_sessionId.value = history.sessionId
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
@ -450,7 +450,11 @@ class ChatController(
}
}
private fun parseHistory(historyJson: String, sessionKey: String): ChatHistory {
private fun parseHistory(
historyJson: String,
sessionKey: String,
previousMessages: List<ChatMessage>,
): ChatHistory {
val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return ChatHistory(sessionKey, null, null, emptyList())
val sid = root["sessionId"].asStringOrNull()
val thinkingLevel = root["thinkingLevel"].asStringOrNull()
@ -470,7 +474,12 @@ class ChatController(
)
}
return ChatHistory(sessionKey = sessionKey, sessionId = sid, thinkingLevel = thinkingLevel, messages = messages)
return ChatHistory(
sessionKey = sessionKey,
sessionId = sid,
thinkingLevel = thinkingLevel,
messages = reconcileMessageIds(previous = previousMessages, incoming = messages),
)
}
private fun parseMessageContent(el: JsonElement): ChatMessageContent? {
@ -519,6 +528,47 @@ class ChatController(
}
}
internal fun reconcileMessageIds(previous: List<ChatMessage>, incoming: List<ChatMessage>): List<ChatMessage> {
if (previous.isEmpty() || incoming.isEmpty()) return incoming
val idsByKey = LinkedHashMap<String, ArrayDeque<String>>()
for (message in previous) {
val key = messageIdentityKey(message) ?: continue
idsByKey.getOrPut(key) { ArrayDeque() }.addLast(message.id)
}
return incoming.map { message ->
val key = messageIdentityKey(message) ?: return@map message
val ids = idsByKey[key] ?: return@map message
val reusedId = ids.removeFirstOrNull() ?: return@map message
if (ids.isEmpty()) {
idsByKey.remove(key)
}
if (reusedId == message.id) return@map message
message.copy(id = reusedId)
}
}
internal fun messageIdentityKey(message: ChatMessage): String? {
val role = message.role.trim().lowercase()
if (role.isEmpty()) return null
val timestamp = message.timestampMs?.toString().orEmpty()
val contentFingerprint =
message.content.joinToString(separator = "\u001E") { part ->
listOf(
part.type.trim().lowercase(),
part.text?.trim().orEmpty(),
part.mimeType?.trim()?.lowercase().orEmpty(),
part.fileName?.trim().orEmpty(),
part.base64?.hashCode()?.toString().orEmpty(),
).joinToString(separator = "\u001F")
}
if (timestamp.isEmpty() && contentFingerprint.isEmpty()) return null
return listOf(role, timestamp, contentFingerprint).joinToString(separator = "|")
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
private fun JsonElement?.asArrayOrNull(): JsonArray? = this as? JsonArray

View File

@ -6,12 +6,14 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@ -34,11 +36,19 @@ fun ChatMessageListCard(
modifier: Modifier = Modifier,
) {
val listState = rememberLazyListState()
val displayMessages = remember(messages) { messages.asReversed() }
val stream = streamingAssistantText?.trim()
// With reverseLayout the newest item is at index 0 (bottom of screen).
LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size, streamingAssistantText) {
// New list items/tool rows should animate into view, but token streaming should not restart
// that animation on every delta.
LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size) {
listState.animateScrollToItem(index = 0)
}
LaunchedEffect(stream) {
if (!stream.isNullOrEmpty()) {
listState.scrollToItem(index = 0)
}
}
Box(modifier = modifier.fillMaxWidth()) {
LazyColumn(
@ -50,8 +60,6 @@ fun ChatMessageListCard(
) {
// With reverseLayout = true, index 0 renders at the BOTTOM.
// So we emit newest items first: streaming → tools → typing → messages (newest→oldest).
val stream = streamingAssistantText?.trim()
if (!stream.isNullOrEmpty()) {
item(key = "stream") {
ChatStreamingAssistantBubble(text = stream)
@ -70,8 +78,8 @@ fun ChatMessageListCard(
}
}
items(count = messages.size, key = { idx -> messages[messages.size - 1 - idx].id }) { idx ->
ChatMessageBubble(message = messages[messages.size - 1 - idx])
items(items = displayMessages, key = { it.id }) { message ->
ChatMessageBubble(message = message)
}
}

View File

@ -160,7 +160,10 @@ private fun ChatThreadSelector(
mainSessionKey: String,
onSelectSession: (String) -> Unit,
) {
val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
val sessionOptions =
remember(sessionKey, sessions, mainSessionKey) {
resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
}
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),

View File

@ -0,0 +1,81 @@
package ai.openclaw.app.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
class ChatControllerMessageIdentityTest {
@Test
fun reconcileMessageIdsReusesMatchingIdsAcrossHistoryReload() {
val previous =
listOf(
ChatMessage(
id = "msg-1",
role = "assistant",
content = listOf(ChatMessageContent(type = "text", text = "hello")),
timestampMs = 1000L,
),
ChatMessage(
id = "msg-2",
role = "user",
content = listOf(ChatMessageContent(type = "text", text = "hi")),
timestampMs = 2000L,
),
)
val incoming =
listOf(
ChatMessage(
id = "new-1",
role = "assistant",
content = listOf(ChatMessageContent(type = "text", text = "hello")),
timestampMs = 1000L,
),
ChatMessage(
id = "new-2",
role = "user",
content = listOf(ChatMessageContent(type = "text", text = "hi")),
timestampMs = 2000L,
),
)
val reconciled = reconcileMessageIds(previous = previous, incoming = incoming)
assertEquals(listOf("msg-1", "msg-2"), reconciled.map { it.id })
}
@Test
fun reconcileMessageIdsLeavesNewMessagesUntouched() {
val previous =
listOf(
ChatMessage(
id = "msg-1",
role = "assistant",
content = listOf(ChatMessageContent(type = "text", text = "hello")),
timestampMs = 1000L,
),
)
val incoming =
listOf(
ChatMessage(
id = "new-1",
role = "assistant",
content = listOf(ChatMessageContent(type = "text", text = "hello")),
timestampMs = 1000L,
),
ChatMessage(
id = "new-2",
role = "assistant",
content = listOf(ChatMessageContent(type = "text", text = "new reply")),
timestampMs = 3000L,
),
)
val reconciled = reconcileMessageIds(previous = previous, incoming = incoming)
assertEquals("msg-1", reconciled[0].id)
assertEquals("new-2", reconciled[1].id)
assertNotEquals(reconciled[0].id, reconciled[1].id)
}
}

View File

@ -5,14 +5,6 @@ vi.mock("./webhook-handler.js", () => ({
createWebhookHandler: vi.fn(() => vi.fn()),
}));
vi.mock("zod", () => ({
z: {
object: vi.fn(() => ({
passthrough: vi.fn(() => ({ _type: "zod-schema" })),
})),
},
}));
const { createSynologyChatPlugin } = await import("./channel.js");
describe("createSynologyChatPlugin", () => {

View File

@ -63,8 +63,8 @@ function installGatewayRuntime(params?: { probeOk?: boolean; botUsername?: strin
.spyOn(probeModule, "probeTelegram")
.mockImplementation(async () =>
params?.probeOk
? { ok: true, bot: { username: params.botUsername ?? "bot" } }
: { ok: false },
? { ok: true, bot: { username: params.botUsername ?? "bot" }, elapsedMs: 0 }
: { ok: false, elapsedMs: 0 },
);
const collectUnmentionedGroupIds = vi
.spyOn(auditModule, "collectTelegramUnmentionedGroupIds")