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

@@ -90,6 +90,25 @@ class FullBleedTurnsTest {
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
fun `marker between assistant and user does not close the agent turn`() {
val a = msg("a1", MessageRole.ASSISTANT)
@@ -26,6 +26,10 @@ class TimelineMarkerTest {
R.string.timeline_marker_internal_notification,
timelineMarkerLabelRes("internal_notification"),
)
assertEquals(
R.string.timeline_marker_max_iterations,
timelineMarkerLabelRes("max_iterations_reached"),
)
}
@Test