fix(chat): preserve usage baseline across local aborts
This commit is contained in:
1 parent
d16a2ea35c
commit
22b948f671
4 files changed
+80
-13
No files matched your search
@@ -1676,17 +1676,6 @@ class ChatViewModel(
|
||||
userMessage: ChatMessage? = null,
|
||||
) {
|
||||
val dispatchGeneration = sessionGeneration
|
||||
// Capture before attachment work or prompt submission can advance the
|
||||
// cumulative counters. The reducer preserves this baseline across
|
||||
// every assistant segment in a tool loop.
|
||||
if (!_streamingState.value.turnUsageBaselineCaptured) {
|
||||
_streamingState.update {
|
||||
it.copy(
|
||||
turnUsageBaseline = _uiState.value.sessionUsage,
|
||||
turnUsageBaselineCaptured = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
AuthManager.setLastOpenedSessionId(storageSessionId)
|
||||
val msgToPersist =
|
||||
userMessage ?: ChatMessage(
|
||||
@@ -1826,6 +1815,7 @@ class ChatViewModel(
|
||||
if (dispatchGeneration == sessionGeneration) {
|
||||
ActiveSessionHolder.set(agentSessionId, storageSessionId)
|
||||
}
|
||||
captureTurnUsageBaselineIfNeeded()
|
||||
if (wasStreaming && attachments.isEmpty()) {
|
||||
wsClient.sendRedirect(
|
||||
agentSessionId,
|
||||
@@ -1859,6 +1849,20 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun captureTurnUsageBaselineIfNeeded() {
|
||||
if (_streamingState.value.turnUsageBaselineCaptured) return
|
||||
_streamingState.update {
|
||||
if (it.turnUsageBaselineCaptured) {
|
||||
it
|
||||
} else {
|
||||
it.copy(
|
||||
turnUsageBaseline = _uiState.value.sessionUsage,
|
||||
turnUsageBaselineCaptured = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot one URI to private cache while enforcing the outbound frame limit. */
|
||||
private suspend fun prepareAttachment(attachment: Attachment): PrepareAttachmentResult {
|
||||
var encodedFile: File? = null
|
||||
|
||||
@@ -15,6 +15,8 @@ private fun List<ChatMessage>.upsertById(message: ChatMessage): List<ChatMessage
|
||||
|
||||
private fun Long.toIntOrNullSafely(): Int? = takeIf { it in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong() }?.toInt()
|
||||
|
||||
private fun Double?.validTpsOrNull(): Double? = takeIf { it != null && it.isFinite() && it > 0.0 }
|
||||
|
||||
internal fun applyUsageSnapshot(
|
||||
state: ChatUiState,
|
||||
snapshot: com.m57.hermescontrol.data.model.UsageSnapshotResponse,
|
||||
@@ -432,7 +434,7 @@ object ChatWsEventReducer {
|
||||
streamingState = StreamingState(),
|
||||
)
|
||||
}
|
||||
val tps = (turnUsage?.avgTps ?: usageState.latestTps)?.takeIf { it.isFinite() && it > 0.0 }
|
||||
val tps = turnUsage?.avgTps.validTpsOrNull() ?: usageState.latestTps.validTpsOrNull()
|
||||
val tokenCount =
|
||||
if (turnUsage?.outputTokens != null) {
|
||||
turnUsage.outputTokens.toIntOrNullSafely()
|
||||
|
||||
@@ -1883,7 +1883,7 @@ class ChatViewModelTest {
|
||||
@Test
|
||||
fun sendMessage_oversizedDuringSnapshotLeavesNoPersistedGhostMessage() =
|
||||
runTest {
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
val (viewModel, sessionId) = createViewModelWithSession()
|
||||
val persistedBeforeSend = fakeRepo.dao.count()
|
||||
val uriString = "content://unknown/growing-file"
|
||||
val mockUri = mockk<Uri>()
|
||||
@@ -1920,6 +1920,7 @@ class ChatViewModelTest {
|
||||
viewModel.sendMessage("Inspect changing file")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertFalse(viewModel.streamingState.value.turnUsageBaselineCaptured)
|
||||
assertEquals(persistedBeforeSend, fakeRepo.dao.count())
|
||||
assertFalse(
|
||||
fakeRepo.dao
|
||||
@@ -1931,6 +1932,24 @@ class ChatViewModelTest {
|
||||
viewModel.uiState.value.messages
|
||||
.any { it.content == "Inspect changing file" },
|
||||
)
|
||||
|
||||
mockEventsFlow.emit(
|
||||
WsEvent.SessionUsage(
|
||||
data = mapOf("usage" to mapOf("output" to 1300L)),
|
||||
sessionId = sessionId,
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
viewModel.removeAttachment(0)
|
||||
viewModel.sendMessage("Retry without attachment")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(
|
||||
1300L,
|
||||
viewModel.streamingState.value.turnUsageBaseline
|
||||
?.outputTokens,
|
||||
)
|
||||
assertTrue(viewModel.streamingState.value.turnUsageBaselineCaptured)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -4191,6 +4210,7 @@ class ChatViewModelTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.sendMessage("prompt")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(
|
||||
1000L,
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.m57.hermescontrol.data.model.UsageSnapshotResponse
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@@ -1185,6 +1186,46 @@ class ChatWsEventReducerTest {
|
||||
assertTrue(!result.streamingState.turnUsageBaselineCaptured)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMessageComplete_invalidFinalTpsFallsBackToLatestValidTps() {
|
||||
fun complete(
|
||||
finalTps: Double?,
|
||||
latestTps: Double?,
|
||||
): Double? {
|
||||
val state =
|
||||
ChatUiState(
|
||||
currentSessionId = "session-1",
|
||||
latestTps = latestTps,
|
||||
sessionUsage = UsageSnapshotResponse(outputTokens = 1000),
|
||||
)
|
||||
val start =
|
||||
ChatWsEventReducer.reduce(
|
||||
state,
|
||||
StreamingState(),
|
||||
WsEvent.MessageStart("session-1"),
|
||||
"session-1",
|
||||
)
|
||||
val usage = mutableMapOf<String, Any?>("output" to 1250L)
|
||||
if (finalTps != null) usage["avg_tps"] = finalTps
|
||||
return ChatWsEventReducer
|
||||
.reduce(
|
||||
state,
|
||||
start.streamingState,
|
||||
WsEvent.MessageComplete("reply", "session-1", rawPayload = mapOf("usage" to usage)),
|
||||
"session-1",
|
||||
).state.messages
|
||||
.single()
|
||||
.tps
|
||||
}
|
||||
|
||||
assertEquals(42.5, complete(42.5, 30.0) ?: 0.0, 0.001)
|
||||
assertEquals(37.5, complete(0.0, 37.5) ?: 0.0, 0.001)
|
||||
assertEquals(37.5, complete(-2.0, 37.5) ?: 0.0, 0.001)
|
||||
assertEquals(37.5, complete(null, 37.5) ?: 0.0, 0.001)
|
||||
assertNull(complete(-2.0, null))
|
||||
assertNull(complete(0.0, -1.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMessageComplete_withoutBaselineFallsBackToVisibleTextEstimate() {
|
||||
val result =
|
||||
|
||||
Reference in new issue
Block a user