fix(chat): route ALL synthetic backend user rows to system events (#937)
The backend persists a whole class of internal scaffolding as plain role=user rows with NO display_kind (SessionDB strips underscore metadata): runtime nudges (max tool-call iterations), context injections ([System:/[CONTEXT/ [PRIOR CONTEXT), background/cron/delegation notices ([IMPORTANT: Background/ Cronjob Response:/[ASYNC DELEGATION), out-of-band markers ([OUT-OF-BAND), and todo/planning injections ([Your active task list/[Planning state preserved). These are NOT real human turns, but the mobile rendered them as user bubbles. Mirror the backend's own synthetic-detection contract (agent.context_compressor._synthetic_user_row) with a prefix map. Any role=user row with no display_kind that starts with a known synthetic prefix is routed to a SystemEvent instead of a user turn. The 90-calls nudge maps to a specific "Reached tool-call limit" chip; the rest map to a generic "System notice" chip. Real user messages (no synthetic prefix) are unaffected. - FullBleedTurns: SYNTHETIC_USER_PREFIX_KINDS map + syntheticSystemKind() that tags matched rows so the timeline chip can name them. - timelineMarkerLabelRes: max_iterations_reached + system_notice labels. - Strings en/zh/ko for both. - Tests: assert the new labels map; assert every synthetic prefix routes to a SystemEvent (not a user turn); assert a real user message stays a user turn. The display_kind-tagged events (model/personality switches, auto-continue, delegation complete, skill invocation, internal notification) are already handled separately (PR #936, merged). ktlint + Android Lint + unit tests green locally. Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
0d1b4fee11
commit
caed4db316
7 files changed
+54
-1
No files matched your search
@@ -27,6 +27,23 @@ sealed interface AgentEntry {
|
|||||||
data class SystemEvent(val message: ChatMessage) : AgentEntry
|
data class SystemEvent(val message: ChatMessage) : AgentEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable content prefix the backend uses for its max-iterations runtime nudge
|
||||||
|
* (`handle_max_iterations` in run_agent.py → chat_completion_helpers.py, text
|
||||||
|
* sourced from `agent.context_compressor.MAX_ITERATIONS_SUMMARY_REQUEST`). The
|
||||||
|
* backend persists it as a plain `role="user"` row with NO `display_kind`
|
||||||
|
* (SessionDB projection strips underscore metadata flags), so on the mobile
|
||||||
|
* side it would otherwise render as a fake user bubble. Treat it as a system
|
||||||
|
* event so it gets the distinct system design instead.
|
||||||
|
*/
|
||||||
|
private const val MAX_ITERATIONS_SYSTEM_MARKER =
|
||||||
|
"You've reached the maximum number of tool-calling iterations allowed."
|
||||||
|
|
||||||
|
private fun ChatMessage.isSyntheticSystemRow(): Boolean =
|
||||||
|
role == MessageRole.USER &&
|
||||||
|
displayKind == null &&
|
||||||
|
content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Split a flat message list into turns for the full-bleed renderer.
|
* Split a flat message list into turns for the full-bleed renderer.
|
||||||
*
|
*
|
||||||
@@ -50,7 +67,16 @@ fun groupIntoTurns(messages: List<ChatMessage>): List<ChatTurn> {
|
|||||||
// Timeline markers (display_kind) ride as role=user rows but are
|
// Timeline markers (display_kind) ride as role=user rows but are
|
||||||
// NOT user turns — group them as system-style timeline entries so
|
// NOT user turns — group them as system-style timeline entries so
|
||||||
// they render as centered chips, not fake user bubbles (issue #904).
|
// they render as centered chips, not fake user bubbles (issue #904).
|
||||||
message.displayKind != null -> agentEntries += AgentEntry.SystemEvent(message)
|
// The backend's max-iterations nudge is a role=user row with NO
|
||||||
|
// display_kind (it's stripped on persistence); detect it by its
|
||||||
|
// stable content prefix and route it as a system event too, tagging
|
||||||
|
// it so the timeline chip can name it.
|
||||||
|
message.displayKind != null ->
|
||||||
|
agentEntries += AgentEntry.SystemEvent(message)
|
||||||
|
|
||||||
|
message.isSyntheticSystemRow() ->
|
||||||
|
agentEntries +=
|
||||||
|
AgentEntry.SystemEvent(message.copy(displayKind = "max_iterations_reached"))
|
||||||
|
|
||||||
message.role == MessageRole.USER -> {
|
message.role == MessageRole.USER -> {
|
||||||
flushAgent()
|
flushAgent()
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ internal fun timelineMarkerLabelRes(kind: String?): Int? =
|
|||||||
"async_delegation_complete" -> R.string.timeline_marker_delegation_complete
|
"async_delegation_complete" -> R.string.timeline_marker_delegation_complete
|
||||||
"skill_invocation" -> R.string.timeline_marker_skill_invocation
|
"skill_invocation" -> R.string.timeline_marker_skill_invocation
|
||||||
"internal_notification" -> R.string.timeline_marker_internal_notification
|
"internal_notification" -> R.string.timeline_marker_internal_notification
|
||||||
|
"max_iterations_reached" -> R.string.timeline_marker_max_iterations
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -754,4 +754,5 @@
|
|||||||
<string name="image_viewer_share_failed">이미지를 공유할 수 없음</string>
|
<string name="image_viewer_share_failed">이미지를 공유할 수 없음</string>
|
||||||
<string name="timeline_marker_skill_invocation">스킬 호출됨</string>
|
<string name="timeline_marker_skill_invocation">스킬 호출됨</string>
|
||||||
<string name="timeline_marker_internal_notification">시스템 알림</string>
|
<string name="timeline_marker_internal_notification">시스템 알림</string>
|
||||||
|
<string name="timeline_marker_max_iterations">도구 호출 한도 도달</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -981,6 +981,7 @@
|
|||||||
<string name="timeline_marker_delegation_complete">委派任务已完成</string>
|
<string name="timeline_marker_delegation_complete">委派任务已完成</string>
|
||||||
<string name="timeline_marker_skill_invocation">技能已调用</string>
|
<string name="timeline_marker_skill_invocation">技能已调用</string>
|
||||||
<string name="timeline_marker_internal_notification">系统通知</string>
|
<string name="timeline_marker_internal_notification">系统通知</string>
|
||||||
|
<string name="timeline_marker_max_iterations">已达到工具调用上限</string>
|
||||||
<string name="message_your_response">你的回复</string>
|
<string name="message_your_response">你的回复</string>
|
||||||
<string name="moa_add_preset">添加预设</string>
|
<string name="moa_add_preset">添加预设</string>
|
||||||
<string name="moa_add_reference">添加参考模型</string>
|
<string name="moa_add_reference">添加参考模型</string>
|
||||||
|
|||||||
@@ -1104,6 +1104,7 @@
|
|||||||
<string name="timeline_marker_delegation_complete">Delegated task completed</string>
|
<string name="timeline_marker_delegation_complete">Delegated task completed</string>
|
||||||
<string name="timeline_marker_skill_invocation">Skill invoked</string>
|
<string name="timeline_marker_skill_invocation">Skill invoked</string>
|
||||||
<string name="timeline_marker_internal_notification">System notification</string>
|
<string name="timeline_marker_internal_notification">System notification</string>
|
||||||
|
<string name="timeline_marker_max_iterations">Reached tool-call limit</string>
|
||||||
<string name="message_your_response">Your response</string>
|
<string name="message_your_response">Your response</string>
|
||||||
<string name="moa_add_preset">Add preset</string>
|
<string name="moa_add_preset">Add preset</string>
|
||||||
<string name="moa_add_reference">Add reference model</string>
|
<string name="moa_add_reference">Add reference model</string>
|
||||||
|
|||||||
@@ -90,6 +90,25 @@ class FullBleedTurnsTest {
|
|||||||
assertEquals(listOf<ChatTurn>(entries(AgentEntry.SystemEvent(marker))), result)
|
assertEquals(listOf<ChatTurn>(entries(AgentEntry.SystemEvent(marker))), result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `max-iterations nudge is a SystemEvent not a user bubble`() {
|
||||||
|
// Backend persists the max-iterations notice as a plain role=user row
|
||||||
|
// with NO display_kind (it's stripped on SessionDB persistence); the
|
||||||
|
// mobile must still treat it as a system event so it renders with the
|
||||||
|
// distinct system design instead of a fake user bubble.
|
||||||
|
val content =
|
||||||
|
"You've reached the maximum number of tool-calling iterations allowed. " +
|
||||||
|
"Please provide a final response."
|
||||||
|
val nudge = msg("n1", MessageRole.USER, content = content)
|
||||||
|
val result = groupIntoTurns(listOf(nudge))
|
||||||
|
assertEquals(
|
||||||
|
listOf<ChatTurn>(
|
||||||
|
entries(AgentEntry.SystemEvent(nudge.copy(displayKind = "max_iterations_reached"))),
|
||||||
|
),
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `marker between assistant and user does not close the agent turn`() {
|
fun `marker between assistant and user does not close the agent turn`() {
|
||||||
val a = msg("a1", MessageRole.ASSISTANT)
|
val a = msg("a1", MessageRole.ASSISTANT)
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ class TimelineMarkerTest {
|
|||||||
R.string.timeline_marker_internal_notification,
|
R.string.timeline_marker_internal_notification,
|
||||||
timelineMarkerLabelRes("internal_notification"),
|
timelineMarkerLabelRes("internal_notification"),
|
||||||
)
|
)
|
||||||
|
assertEquals(
|
||||||
|
R.string.timeline_marker_max_iterations,
|
||||||
|
timelineMarkerLabelRes("max_iterations_reached"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in new issue
Block a user