From f6d09e06ca50e19aa2f7aa078faaa727af1e1293 Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Fri, 8 May 2026 12:14:13 +0200 Subject: [PATCH] fix: deduplicate workspace-prefixed user turns --- api/streaming.py | 19 ++++++++++-- tests/test_issue1217_transcript_compaction.py | 30 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/api/streaming.py b/api/streaming.py index e93827c8..1093e5f2 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -586,6 +586,11 @@ def _message_text(value) -> str: return _strip_thinking_markup(str(value or '').strip()) +def _strip_workspace_prefix(text: str) -> str: + """Remove WebUI's model-facing workspace tag from display identity text.""" + return re.sub(r'^\s*\[Workspace:[^\]]+\]\s*', '', str(text or '')).strip() + + def _first_exchange_snippets(messages): """Return (first_user_text, first_assistant_text) snippets for title generation. @@ -1433,6 +1438,12 @@ def _message_identity(msg): role = str(msg.get('role') or '') content = msg.get('content', '') text = _message_text(content) + if role == 'user': + # WebUI sends the model a workspace-prefixed user_message while the + # visible optimistic bubble contains only the human text. Treat them as + # the same turn for merge/dedup purposes; otherwise compaction results + # render two adjacent user bubbles ("Ok" and "[Workspace...]\nOk"). + text = _strip_workspace_prefix(text) if not text and not msg.get('tool_call_id') and not msg.get('tool_calls'): return None return ( @@ -1471,7 +1482,7 @@ def _find_current_user_turn(messages, msg_text): if not isinstance(msg, dict) or msg.get('role') != 'user': continue fallback = idx - text = " ".join(_message_text(msg.get('content', '')).split()) + text = " ".join(_strip_workspace_prefix(_message_text(msg.get('content', ''))).split()) if needle and (needle in text or text in needle): return idx return fallback @@ -1558,7 +1569,11 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex continue if _is_context_compression_marker(msg) and key is not None and key in seen: continue - merged.append(copy.deepcopy(msg)) + display_msg = msg + if key is not None and key == current_user_key and isinstance(msg, dict) and msg.get('role') == 'user': + display_msg = copy.deepcopy(msg) + display_msg['content'] = msg_text + merged.append(copy.deepcopy(display_msg)) if key is not None: seen.add(key) return merged diff --git a/tests/test_issue1217_transcript_compaction.py b/tests/test_issue1217_transcript_compaction.py index 72c6f08a..7d14f124 100644 --- a/tests/test_issue1217_transcript_compaction.py +++ b/tests/test_issue1217_transcript_compaction.py @@ -47,6 +47,36 @@ def test_session_persists_model_context_separately_from_display_transcript(tmp_p assert _sanitize_messages_for_api(_session_context_messages(reloaded)) == compacted_context +def test_workspace_prefixed_current_user_after_compaction_is_not_duplicated(): + previous_display = [ + {"role": "user", "content": "older prompt"}, + {"role": "assistant", "content": "older answer"}, + ] + previous_context = list(previous_display) + compacted_result = [ + { + "role": "assistant", + "content": "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted.", + }, + {"role": "user", "content": "[Workspace: /home/manfred/.hermes/workspace]\nOk, mache weiter"}, + {"role": "assistant", "content": "continuing"}, + ] + + merged = _merge_display_messages_after_agent_result( + previous_display, + previous_context, + compacted_result, + "Ok, mache weiter", + ) + + assert [m["role"] for m in merged] == ["user", "assistant", "assistant", "user", "assistant"] + assert [m["content"] for m in merged[-2:]] == [ + "Ok, mache weiter", + "continuing", + ] + assert sum(1 for m in merged if m.get("role") == "user" and "Ok, mache weiter" in m.get("content", "")) == 1 + + def test_compacted_agent_result_keeps_old_prompts_and_appends_current_turn(): previous_display = [ {"role": "user", "content": "first prompt that must remain visible"},