mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-09-12 21:30:02 -04:00
fix(ai): bound assistant messages at what the send path will actually encode (#6970)
This commit is contained in:
1 parent
ef4e4154f2
commit
ab0c8d4dba
3 files changed
+101
-3
No files matched your search
+1
-1
@@ -42,7 +42,7 @@ class MeshtasticAppFunctions(private val provider: AiFunctionProvider) {
|
||||
* communications where cellular service is unavailable.
|
||||
*
|
||||
* @param context The app function invocation context provided by the system.
|
||||
* @param text The message text to send (max 237 bytes).
|
||||
* @param text The message text to send (max 228 UTF-8 bytes — the mesh payload left after protobuf framing).
|
||||
* @param recipientName Optional name of a specific node to send a direct message to. If omitted, the message is
|
||||
* broadcast to all nodes on the specified channel.
|
||||
* @param channelName Optional channel name to broadcast on. If omitted, uses the primary channel. Ignored when
|
||||
|
||||
+20
-2
@@ -26,6 +26,7 @@ import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.repository.usecase.SendMessageUseCase
|
||||
import org.meshtastic.proto.Constants
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -514,8 +515,25 @@ class AiFunctionProviderImpl(
|
||||
private const val MESSAGES_PER_CONTACT = 5
|
||||
private const val MESSAGE_PREVIEW_MAX_LENGTH = 100
|
||||
|
||||
/** Standard Meshtastic message payload limit (bytes). */
|
||||
const val MAX_MESSAGE_LENGTH = 237
|
||||
/**
|
||||
* Protobuf framing a text message costs inside the `Data` envelope that
|
||||
* [org.meshtastic.core.data.manager.CommandSenderImpl.sendData] size-checks:
|
||||
* * `portnum` tag + varint value: 2 bytes
|
||||
* * `payload` tag: 1 byte
|
||||
* * `payload` length varint: 2 bytes (any payload of 128 bytes or more)
|
||||
* * `reply_id` / `emoji`: 0 bytes, both are proto3 identity values here
|
||||
*/
|
||||
private const val TEXT_DATA_ENVELOPE_BYTES = 5
|
||||
|
||||
/**
|
||||
* Largest text message (UTF-8 bytes, not characters) that survives the send path.
|
||||
*
|
||||
* `Constants.DATA_PAYLOAD_LEN` is 233, and `CommandSenderImpl.sendData` applies it to the **whole encoded
|
||||
* `Data` proto** via `Data.ADAPTER.isWithinSizeLimit`, not to the `payload` field alone — so the text budget is
|
||||
* 233 less [TEXT_DATA_ENVELOPE_BYTES]. Checking it here returns `InvalidArgument` before the send path runs, so
|
||||
* an over-long message is never written to history.
|
||||
*/
|
||||
val MAX_MESSAGE_LENGTH = Constants.DATA_PAYLOAD_LEN.value - TEXT_DATA_ENVELOPE_BYTES
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+80
@@ -19,22 +19,32 @@ package org.meshtastic.core.data.ai
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.answering.returns
|
||||
import dev.mokkery.every
|
||||
import dev.mokkery.everySuspend
|
||||
import dev.mokkery.matcher.any
|
||||
import dev.mokkery.mock
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.util.isWithinSizeLimit
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.repository.usecase.SendMessageUseCase
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.Constants
|
||||
import org.meshtastic.proto.Data
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.User
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
@@ -253,6 +263,76 @@ class AiFunctionProviderImplTest {
|
||||
assertIs<SendMessageResult.RateLimited>(result)
|
||||
}
|
||||
|
||||
// --- sendMessage length boundary tests ---
|
||||
//
|
||||
// The ceiling is UTF-8 *bytes*, not characters: CommandSenderImpl.sendData applies
|
||||
// Constants.DATA_PAYLOAD_LEN (233) to the whole encoded Data proto, leaving
|
||||
// AiFunctionProviderImpl.MAX_MESSAGE_LENGTH bytes for the text itself.
|
||||
|
||||
@Test
|
||||
fun sendMessage_accepts_text_exactly_at_the_byte_limit() = runTest {
|
||||
every { radioConfigRepository.channelSetFlow } returns flowOf(ChannelSet())
|
||||
everySuspend { sendMessageUseCase.invoke(any(), any(), any()) } returns 42
|
||||
|
||||
val text = "a".repeat(AiFunctionProviderImpl.MAX_MESSAGE_LENGTH)
|
||||
assertEquals(AiFunctionProviderImpl.MAX_MESSAGE_LENGTH, text.encodeToByteArray().size)
|
||||
|
||||
val result = createProvider().sendMessage(text, null, null)
|
||||
|
||||
assertIs<SendMessageResult.Success>(result)
|
||||
assertEquals(42, result.messageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendMessage_rejects_text_one_byte_over_the_limit() = runTest {
|
||||
val overBy = AiFunctionProviderImpl.MAX_MESSAGE_LENGTH + 1
|
||||
val text = "a".repeat(overBy)
|
||||
|
||||
val result = createProvider().sendMessage(text, null, null)
|
||||
|
||||
val invalid = assertIs<SendMessageResult.InvalidArgument>(result)
|
||||
assertTrue(invalid.reason.contains("$overBy bytes"), "actual size should be reported: ${invalid.reason}")
|
||||
assertTrue(
|
||||
invalid.reason.contains("${AiFunctionProviderImpl.MAX_MESSAGE_LENGTH} bytes"),
|
||||
"the real limit should be reported: ${invalid.reason}",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendMessage_counts_multi_byte_text_in_bytes_not_characters() = runTest {
|
||||
every { radioConfigRepository.channelSetFlow } returns flowOf(ChannelSet())
|
||||
everySuspend { sendMessageUseCase.invoke(any(), any(), any()) } returns 7
|
||||
|
||||
// "\u00fc" is two UTF-8 bytes, so half as many characters fit.
|
||||
val fits = "\u00fc".repeat(AiFunctionProviderImpl.MAX_MESSAGE_LENGTH / 2)
|
||||
assertEquals(AiFunctionProviderImpl.MAX_MESSAGE_LENGTH, fits.encodeToByteArray().size)
|
||||
assertIs<SendMessageResult.Success>(createProvider().sendMessage(fits, null, null))
|
||||
|
||||
// One more character is only one more *character* but two more bytes.
|
||||
val overflows = fits + "\u00fc"
|
||||
assertTrue(overflows.length < AiFunctionProviderImpl.MAX_MESSAGE_LENGTH, "must be under the limit in chars")
|
||||
assertIs<SendMessageResult.InvalidArgument>(createProvider().sendMessage(overflows, null, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun maxMessageLength_is_the_largest_text_the_send_path_will_encode() {
|
||||
// Pins the constant to the predicate CommandSenderImpl.sendData actually applies, so a change to the
|
||||
// Data proto's framing fails here instead of in the send queue.
|
||||
fun encodesWithinLimit(byteCount: Int): Boolean {
|
||||
val data =
|
||||
Data(
|
||||
portnum = PortNum.TEXT_MESSAGE_APP,
|
||||
payload = ByteArray(byteCount) { 'a'.code.toByte() }.toByteString(),
|
||||
reply_id = 0,
|
||||
emoji = 0,
|
||||
)
|
||||
return Data.ADAPTER.isWithinSizeLimit(data, Constants.DATA_PAYLOAD_LEN.value)
|
||||
}
|
||||
|
||||
assertTrue(encodesWithinLimit(AiFunctionProviderImpl.MAX_MESSAGE_LENGTH), "the limit itself must fit")
|
||||
assertFalse(encodesWithinLimit(AiFunctionProviderImpl.MAX_MESSAGE_LENGTH + 1), "one byte over must not fit")
|
||||
}
|
||||
|
||||
// --- getRecentMessages tests ---
|
||||
|
||||
@Test
|
||||
|
||||
Reference in new issue
Block a user