diff --git a/CHANGELOG.md b/CHANGELOG.md index ac53bbb3e..b1d4bffc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- **Final answers no longer duplicate into the Compact Worklog as reasoning when a runtime mirrors already-streamed answer text through the reasoning callback.** WebUI now suppresses substantial reasoning chunks that are already present in the visible assistant stream before they reach live SSE or the run journal, while preserving genuine reasoning and short progress-tail echo handling. This keeps live rendering, journal replay, and settled session reloads from showing Final Answer prose inside Worklog/Thinking. + ## [v0.51.617] — 2026-06-24 — Release VX (fix mobile scroll-jank to top during streaming) ### Fixed diff --git a/api/streaming.py b/api/streaming.py index 57b51d05f..bdfb8e7dd 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -6384,10 +6384,22 @@ def _run_agent_streaming( candidate = _compact_for_echo_compare(text) if not candidate: return False + visible_output = STREAM_PARTIAL_TEXT.get(stream_id, '') visible_tail = _compact_for_echo_compare( - STREAM_PARTIAL_TEXT.get(stream_id, '')[-max(len(str(text)) * 2, 512):] + visible_output[-max(len(str(text)) * 2, 512):] ) - return bool(visible_tail and visible_tail.endswith(candidate)) + if visible_tail and visible_tail.endswith(candidate): + return True + # Some runtimes can report a prefix of the already-streamed final + # answer through reasoning after visible output has completed. That + # prefix is not a tail echo, so catch only substantial chunks that + # are already present in the visible assistant stream. Short text + # stays on the stricter suffix path to avoid hiding genuine + # reasoning that happens to reuse an answer phrase. + if len(candidate) < 80: + return False + visible_compact = _compact_for_echo_compare(visible_output) + return bool(visible_compact and candidate in visible_compact) def _strip_reasoning_output_echo(text: str) -> bool: nonlocal _reasoning_segments diff --git a/tests/test_issue_progress_echo_dedupe.py b/tests/test_issue_progress_echo_dedupe.py index b5243111c..4ea853ba5 100644 --- a/tests/test_issue_progress_echo_dedupe.py +++ b/tests/test_issue_progress_echo_dedupe.py @@ -134,11 +134,11 @@ def test_visible_progress_token_reasoning_and_interim_are_deduped(cleanup_test_s "credential_pool": None, } runtime_payload["api_" + "key"] = "***" - fake_runtime_module.resolve_runtime_provider = mock.Mock(return_value=runtime_payload) + fake_runtime_module.__dict__["resolve_runtime_provider"] = mock.Mock(return_value=runtime_payload) fake_hermes_cli = types.ModuleType("hermes_cli") - fake_hermes_cli.runtime_provider = fake_runtime_module + fake_hermes_cli.__dict__["runtime_provider"] = fake_runtime_module fake_hermes_state = types.ModuleType("hermes_state") - fake_hermes_state.SessionDB = mock.Mock(return_value=None) + fake_hermes_state.__dict__["SessionDB"] = mock.Mock(return_value=None) injected = { "hermes_cli": fake_hermes_cli, "hermes_cli.runtime_provider": fake_runtime_module, @@ -298,11 +298,11 @@ def test_reasoning_then_interim_progress_marks_reasoning_echo(cleanup_test_sessi "credential_pool": None, } runtime_payload["api_" + "key"] = "***" - fake_runtime_module.resolve_runtime_provider = mock.Mock(return_value=runtime_payload) + fake_runtime_module.__dict__["resolve_runtime_provider"] = mock.Mock(return_value=runtime_payload) fake_hermes_cli = types.ModuleType("hermes_cli") - fake_hermes_cli.runtime_provider = fake_runtime_module + fake_hermes_cli.__dict__["runtime_provider"] = fake_runtime_module fake_hermes_state = types.ModuleType("hermes_state") - fake_hermes_state.SessionDB = mock.Mock(return_value=None) + fake_hermes_state.__dict__["SessionDB"] = mock.Mock(return_value=None) injected = { "hermes_cli": fake_hermes_cli, "hermes_cli.runtime_provider": fake_runtime_module, @@ -343,3 +343,197 @@ def test_reasoning_then_interim_progress_marks_reasoning_echo(cleanup_test_sessi assert done_payloads, "run should settle" final_messages = done_payloads[-1]["session"]["messages"] assert not any(message.get("reasoning") == progress for message in final_messages) + + +def test_final_answer_prefix_reasoning_echo_is_not_journaled_or_merged(cleanup_test_sessions): + """A final-answer prefix mirrored through reasoning must not enter Worklog. + + The observed production failure had the final answer stream normally through + token events, then a later reasoning event carried the first 500 characters + of that same final answer. Since `put()` journals before queue delivery, this + regression covers the live stream and run-journal replay boundary together; + the done payload covers final session merge/reload state. + """ + import api.streaming as streaming + from api.run_journal import read_run_events + + final_answer = ( + "已按 Hermes WebUI workflow 在独立 worktree 完成,本地 review-ready;" + "没有 push、没有开 PR、没有改真实 cron/config,也没有在主 checkout 实现。\n\n" + "## 位置\n\n" + "| 项 | 值 |\n|---|---|\n" + "| Worktree | `/Users/xuefusong/hermes-webui-worktrees/example` |\n" + "| Branch | `fix/example` |\n\n" + "## 根因\n\n" + "Final Answer 正文已经作为 assistant token 流出,不应该再作为 reasoning/Worklog 事件出现。\n\n" + "## 验证\n\n" + "相关 targeted regression 覆盖 live stream、run journal replay 和 final merge。" + ) + leaked_prefix = final_answer[:500] + + class FakeSession: + def __init__(self): + self.session_id = "issue_final_answer_reasoning_echo" + self.title = "Final echo" + self.workspace = "/tmp" + self.model = "gpt-test" + self.model_provider = None + self.profile = None + self.personality = None + self.messages = [] + self.context_messages = [] + self.input_tokens = 0 + self.output_tokens = 0 + self.estimated_cost = 0 + self.cache_read_tokens = 0 + self.cache_write_tokens = 0 + self.tool_calls = [] + self.gateway_routing = None + self.gateway_routing_history = [] + self.active_stream_id = "" + self.pending_user_message = None + self.pending_attachments = [] + self.pending_started_at = None + self.context_length = 0 + self.threshold_tokens = 0 + self.last_prompt_tokens = 0 + self.llm_title_generated = True + + def save(self, *args, **kwargs): + pass + + def compact(self): + return { + "session_id": self.session_id, + "title": self.title, + "workspace": self.workspace, + "model": self.model, + "created_at": 0, + "updated_at": 0, + "pinned": False, + "archived": False, + "project_id": None, + "profile": self.profile, + "input_tokens": self.input_tokens, + "output_tokens": self.output_tokens, + "estimated_cost": self.estimated_cost, + "cache_read_tokens": self.cache_read_tokens, + "cache_write_tokens": self.cache_write_tokens, + "personality": self.personality, + } + + class FinalEchoAgent: + def __init__( + self, + model=None, + provider=None, + base_url=None, + platform=None, + quiet_mode=False, + enabled_toolsets=None, + fallback_model=None, + session_id=None, + session_db=None, + prefill_messages=None, + stream_delta_callback=None, + reasoning_callback=None, + tool_progress_callback=None, + clarify_callback=None, + interim_assistant_callback=None, + **_kwargs, + ): + self.stream_delta_callback = cast(Callable[[str], None], stream_delta_callback) + self.reasoning_callback = cast(Callable[[str], None], reasoning_callback) + self.context_compressor = None + self.session_prompt_tokens = 0 + self.session_completion_tokens = 0 + self.session_estimated_cost_usd = 0 + self.session_cache_read_tokens = 0 + self.session_cache_write_tokens = 0 + self.reasoning_config = None + self.ephemeral_system_prompt = None + self._last_error = None + + def run_conversation(self, **kwargs): + self.stream_delta_callback(final_answer) + # This mirrors the production journal shape: final answer content was + # emitted as visible tokens first, then incorrectly reported as a + # reasoning delta near stream end. + self.reasoning_callback(leaked_prefix) + history = kwargs.get("conversation_history", []) + return {"messages": history + [ + {"role": "user", "content": kwargs["persist_user_message"]}, + {"role": "assistant", "content": final_answer}, + ]} + + def interrupt(self, _message): + pass + + fake_session = FakeSession() + fake_stream_id = "stream_issue_final_answer_reasoning_echo" + fake_session.active_stream_id = fake_stream_id + fake_queue = queue.Queue() + fake_runtime_module = types.ModuleType("hermes_cli.runtime_provider") + runtime_payload = { + "provider": "openai", + "base_url": None, + "api_mode": "chat_completions", + "command": None, + "args": [], + "credential_pool": None, + } + runtime_payload["api_" + "key"] = "***" + fake_runtime_module.__dict__["resolve_runtime_provider"] = mock.Mock(return_value=runtime_payload) + fake_hermes_cli = types.ModuleType("hermes_cli") + fake_hermes_cli.__dict__["runtime_provider"] = fake_runtime_module + fake_hermes_state = types.ModuleType("hermes_state") + fake_hermes_state.__dict__["SessionDB"] = mock.Mock(return_value=None) + injected = { + "hermes_cli": fake_hermes_cli, + "hermes_cli.runtime_provider": fake_runtime_module, + "hermes_state": fake_hermes_state, + } + saved = {k: sys.modules.get(k, _MISSING) for k in injected} + sys.modules.update(injected) + try: + with mock.patch.object(streaming, "get_session", return_value=fake_session), \ + mock.patch.object(streaming, "_get_ai_agent", return_value=FinalEchoAgent), \ + mock.patch.object(streaming, "resolve_model_provider", return_value=("gpt-test", "openai", None)), \ + mock.patch("api.config.get_config", return_value={}), \ + mock.patch("api.config._resolve_cli_toolsets", return_value=[]): + streaming.STREAMS[fake_stream_id] = fake_queue + streaming._run_agent_streaming( + session_id=fake_session.session_id, + msg_text="ship it", + model="gpt-test", + workspace="/tmp", + stream_id=fake_stream_id, + ) + finally: + streaming.STREAMS.pop(fake_stream_id, None) + for k, prev in saved.items(): + if prev is _MISSING: + sys.modules.pop(k, None) + else: + sys.modules[k] = cast(types.ModuleType, prev) + + events = list(fake_queue.queue) + assert [(event, payload) for event, payload in events if event == "token"] == [ + ("token", {"text": final_answer}) + ] + assert not [payload for event, payload in events if event == "reasoning" and payload.get("text") == leaked_prefix] + + journal_events = read_run_events(fake_session.session_id, fake_stream_id)["events"] + assert any(event.get("type") == "token" and event.get("payload", {}).get("text") == final_answer for event in journal_events) + assert not [ + event for event in journal_events + if event.get("type") == "reasoning" and event.get("payload", {}).get("text") == leaked_prefix + ] + + done_payloads = [payload for event, payload in events if event == "done"] + assert done_payloads, "run should settle" + final_messages = done_payloads[-1]["session"]["messages"] + assistant_messages = [message for message in final_messages if message.get("role") == "assistant"] + assert assistant_messages[-1]["content"] == final_answer + assert leaked_prefix not in str(assistant_messages[-1].get("reasoning") or "") + assert leaked_prefix not in str(assistant_messages[-1].get("reasoning_content") or "")