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:
M57andM57 authored and GitHub committed 2026-08-18 19:04:44 +03:00
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
}
/**
* 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.
*
@@ -50,7 +67,16 @@ fun groupIntoTurns(messages: List<ChatMessage>): List<ChatTurn> {
// Timeline markers (display_kind) ride as role=user rows but are
// NOT user turns — group them as system-style timeline entries so
// 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 -> {
flushAgent()
@@ -30,6 +30,7 @@ internal fun timelineMarkerLabelRes(kind: String?): Int? =
"async_delegation_complete" -> R.string.timeline_marker_delegation_complete
"skill_invocation" -> R.string.timeline_marker_skill_invocation
"internal_notification" -> R.string.timeline_marker_internal_notification
"max_iterations_reached" -> R.string.timeline_marker_max_iterations
else -> null
}