Merge pull request #1205 from Hy4ri/feat/backend-only-context-meter
fix(chat): make context meter strictly backend-reported
This commit is contained in:
+110
@@ -0,0 +1,110 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.filters.MediumTest
|
||||||
|
import com.m57.hermescontrol.theme.HermesControlTheme
|
||||||
|
import com.m57.hermescontrol.ui.chat.ContextBreakdown
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression coverage for backend-reported context occupancy.
|
||||||
|
*
|
||||||
|
* Cumulative REST input-token accounting must never masquerade as the live
|
||||||
|
* context-window numerator when the backend has not reported context_used.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@MediumTest
|
||||||
|
class ContextUsageComponentsTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun chip_missingBackendOccupancy_doesNotShowFakeZeroPercent() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
HermesControlTheme {
|
||||||
|
ContextUsageChip(
|
||||||
|
usedTokens = null,
|
||||||
|
fullTokens = 128_000L,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("— / 128k context")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("0%")
|
||||||
|
.assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun chip_backendOccupancyAvailable_showsDeterminateUsage() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
HermesControlTheme {
|
||||||
|
ContextUsageChip(
|
||||||
|
usedTokens = 64_000L,
|
||||||
|
fullTokens = 128_000L,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("64k / 128k context")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("50%")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Test
|
||||||
|
fun detailSheet_missingBackendOccupancy_doesNotUseCumulativeInputTokensAsNumerator() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
HermesControlTheme {
|
||||||
|
ContextDetailSheet(
|
||||||
|
breakdown =
|
||||||
|
ContextBreakdown(
|
||||||
|
inputTokens = 12_300L,
|
||||||
|
outputTokens = 2_000L,
|
||||||
|
cacheReadTokens = 500L,
|
||||||
|
cacheWriteTokens = 250L,
|
||||||
|
reasoningTokens = 100L,
|
||||||
|
messageCount = 4,
|
||||||
|
),
|
||||||
|
usedTokens = null,
|
||||||
|
fullTokens = 128_000L,
|
||||||
|
onDismiss = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The live numerator is unavailable, even though cumulative REST
|
||||||
|
// input-token accounting exists below in the detail breakdown.
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("—")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("Context window usage unavailable")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
|
||||||
|
// Cumulative accounting is still preserved as informational detail.
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("12.3k")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
|
||||||
|
// Most important regression guard: null occupancy is not rendered as
|
||||||
|
// a determinate zero-percent context state.
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("0% of context window used")
|
||||||
|
.assertDoesNotExist()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,12 +51,12 @@ fun ContextDetailSheet(
|
|||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
) {
|
) {
|
||||||
val full = fullTokens ?: 0L
|
val full = fullTokens ?: 0L
|
||||||
val used = usedTokens ?: breakdown.inputTokens
|
val fraction = if (full > 0L && usedTokens != null) min(1f, usedTokens.toFloat() / full.toFloat()) else 0f
|
||||||
val fraction = if (full > 0L) min(1f, used.toFloat() / full.toFloat()) else 0f
|
|
||||||
val pct = (fraction * 100).toInt()
|
val pct = (fraction * 100).toInt()
|
||||||
val statusColors = LocalHermesStatusColors.current
|
val statusColors = LocalHermesStatusColors.current
|
||||||
val barColor =
|
val barColor =
|
||||||
when {
|
when {
|
||||||
|
usedTokens == null -> MaterialTheme.colorScheme.primary
|
||||||
pct >= 90 -> statusColors.error
|
pct >= 90 -> statusColors.error
|
||||||
pct >= 70 -> statusColors.warning
|
pct >= 70 -> statusColors.warning
|
||||||
else -> MaterialTheme.colorScheme.primary
|
else -> MaterialTheme.colorScheme.primary
|
||||||
@@ -83,7 +83,7 @@ fun ContextDetailSheet(
|
|||||||
verticalAlignment = Alignment.Bottom,
|
verticalAlignment = Alignment.Bottom,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = formatTokens(used),
|
text = if (usedTokens != null) formatTokens(usedTokens) else "—",
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = barColor,
|
color = barColor,
|
||||||
@@ -115,7 +115,12 @@ fun ContextDetailSheet(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = "$pct% of context window used",
|
text =
|
||||||
|
if (usedTokens != null) {
|
||||||
|
"$pct% of context window used"
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.context_window_usage_unavailable)
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -56,12 +56,12 @@ fun ContextUsageChip(
|
|||||||
val full = fullTokens
|
val full = fullTokens
|
||||||
if (full == null || full <= 0L) return
|
if (full == null || full <= 0L) return
|
||||||
|
|
||||||
val used = usedTokens ?: 0L
|
val fraction = if (usedTokens != null) min(1f, usedTokens.toFloat() / full.toFloat()) else 0f
|
||||||
val fraction = min(1f, used.toFloat() / full.toFloat())
|
|
||||||
val pct = (fraction * 100).toInt()
|
val pct = (fraction * 100).toInt()
|
||||||
val statusColors = LocalHermesStatusColors.current
|
val statusColors = LocalHermesStatusColors.current
|
||||||
val barColor =
|
val barColor =
|
||||||
when {
|
when {
|
||||||
|
usedTokens == null -> MaterialTheme.colorScheme.primary
|
||||||
pct >= 90 -> statusColors.error
|
pct >= 90 -> statusColors.error
|
||||||
pct >= 70 -> statusColors.warning
|
pct >= 70 -> statusColors.warning
|
||||||
else -> MaterialTheme.colorScheme.primary
|
else -> MaterialTheme.colorScheme.primary
|
||||||
@@ -90,7 +90,7 @@ fun ContextUsageChip(
|
|||||||
Text(
|
Text(
|
||||||
text =
|
text =
|
||||||
if (usedTokens != null) {
|
if (usedTokens != null) {
|
||||||
"${formatTokens(used)} / ${formatTokens(full)} context"
|
"${formatTokens(usedTokens)} / ${formatTokens(full)} context"
|
||||||
} else {
|
} else {
|
||||||
"— / ${formatTokens(full)} context"
|
"— / ${formatTokens(full)} context"
|
||||||
},
|
},
|
||||||
@@ -111,12 +111,14 @@ fun ContextUsageChip(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
if (usedTokens != null) {
|
||||||
text = "$pct%",
|
Text(
|
||||||
style = MaterialTheme.typography.labelSmall,
|
text = "$pct%",
|
||||||
color = barColor,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
color = barColor,
|
||||||
)
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
|
|||||||
@@ -1308,8 +1308,9 @@
|
|||||||
<string name="context_completion_output">الإكمال (المخرجات)</string>
|
<string name="context_completion_output">الإكمال (المخرجات)</string>
|
||||||
<string name="context_compressed">مضغوط ×%1$s</string>
|
<string name="context_compressed">مضغوط ×%1$s</string>
|
||||||
<string name="context_prompt_input">المطالبة (المدخلات)</string>
|
<string name="context_prompt_input">المطالبة (المدخلات)</string>
|
||||||
<string name="context_tokens_note">تعكس رموز المطالبة إجمالي السياق المستخدم في هذه الجلسة حتى الآن.</string>
|
<string name="context_tokens_note">أعداد الرموز أدناه تراكمية للجلسة ولا تمثل الاستخدام الحالي لنافذة السياق.</string>
|
||||||
<string name="context_window">نافذة السياق</string>
|
<string name="context_window">نافذة السياق</string>
|
||||||
|
<string name="context_window_usage_unavailable">استخدام نافذة السياق غير متوفر</string>
|
||||||
<string name="gif_play">تشغيل GIF</string>
|
<string name="gif_play">تشغيل GIF</string>
|
||||||
<string name="kanban_filter_placeholder">تصفية المهام حسب العنوان أو الحالة أو المسؤول...</string>
|
<string name="kanban_filter_placeholder">تصفية المهام حسب العنوان أو الحالة أو المسؤول...</string>
|
||||||
<string name="logs_clipboard_label">سجلات Hermes</string>
|
<string name="logs_clipboard_label">سجلات Hermes</string>
|
||||||
|
|||||||
@@ -1275,8 +1275,9 @@
|
|||||||
<string name="context_completion_output">补全(输出)</string>
|
<string name="context_completion_output">补全(输出)</string>
|
||||||
<string name="context_compressed">已压缩 ×%1$s</string>
|
<string name="context_compressed">已压缩 ×%1$s</string>
|
||||||
<string name="context_prompt_input">提示词(输入)</string>
|
<string name="context_prompt_input">提示词(输入)</string>
|
||||||
<string name="context_tokens_note">提示词 token 反映此会话迄今使用的总上下文。</string>
|
<string name="context_tokens_note">下方 token 数为会话累计用量,不代表当前上下文窗口占用。</string>
|
||||||
<string name="context_window">上下文窗口</string>
|
<string name="context_window">上下文窗口</string>
|
||||||
|
<string name="context_window_usage_unavailable">上下文窗口使用量不可用</string>
|
||||||
<string name="gif_play">播放 GIF</string>
|
<string name="gif_play">播放 GIF</string>
|
||||||
<string name="kanban_filter_placeholder">按标题、状态或负责人筛选任务…</string>
|
<string name="kanban_filter_placeholder">按标题、状态或负责人筛选任务…</string>
|
||||||
<string name="logs_clipboard_label">Hermes 日志</string>
|
<string name="logs_clipboard_label">Hermes 日志</string>
|
||||||
|
|||||||
@@ -1435,8 +1435,9 @@
|
|||||||
<string name="context_completion_output">Completion (output)</string>
|
<string name="context_completion_output">Completion (output)</string>
|
||||||
<string name="context_compressed">compressed ×%1$s</string>
|
<string name="context_compressed">compressed ×%1$s</string>
|
||||||
<string name="context_prompt_input">Prompt (input)</string>
|
<string name="context_prompt_input">Prompt (input)</string>
|
||||||
<string name="context_tokens_note">Prompt tokens reflect total context used by this session so far.</string>
|
<string name="context_tokens_note">Token counts below are cumulative session accounting and do not represent current context-window occupancy.</string>
|
||||||
<string name="context_window">Context window</string>
|
<string name="context_window">Context window</string>
|
||||||
|
<string name="context_window_usage_unavailable">Context window usage unavailable</string>
|
||||||
<string name="gif_play">Play GIF</string>
|
<string name="gif_play">Play GIF</string>
|
||||||
<string name="kanban_filter_placeholder">Filter tasks by title, status, or assignee...</string>
|
<string name="kanban_filter_placeholder">Filter tasks by title, status, or assignee...</string>
|
||||||
<string name="logs_clipboard_label">Hermes Logs</string>
|
<string name="logs_clipboard_label">Hermes Logs</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user