diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt index 06a2fcd6..bbfddc70 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt @@ -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): List { // 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() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TimelineMarkerChip.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TimelineMarkerChip.kt index 44d542e9..88c2d033 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TimelineMarkerChip.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/TimelineMarkerChip.kt @@ -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 } diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index e7917d67..a4b71117 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -754,4 +754,5 @@ 이미지를 공유할 수 없음 스킬 호출됨 시스템 알림 + 도구 호출 한도 도달 \ No newline at end of file diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 05fa74fa..ab4ac1b5 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -981,6 +981,7 @@ 委派任务已完成 技能已调用 系统通知 + 已达到工具调用上限 你的回复 添加预设 添加参考模型 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 75a28862..bd904edd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1104,6 +1104,7 @@ Delegated task completed Skill invoked System notification + Reached tool-call limit Your response Add preset Add reference model diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedTurnsTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedTurnsTest.kt index c6edd87b..a15d2865 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedTurnsTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedTurnsTest.kt @@ -90,6 +90,25 @@ class FullBleedTurnsTest { assertEquals(listOf(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( + 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) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/fullbleed/TimelineMarkerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/fullbleed/TimelineMarkerTest.kt index 4b2cf449..28eae445 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/fullbleed/TimelineMarkerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/fullbleed/TimelineMarkerTest.kt @@ -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