Files
hermes-webui/tests/test_issue_progress_echo_dedupe.py
T
2026-06-24 15:29:25 +08:00

540 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import queue
import sys
import types
from typing import Callable, cast
from unittest import mock
_MISSING = object()
def test_visible_progress_token_reasoning_and_interim_are_deduped(cleanup_test_sessions):
"""Progress text can arrive through three Hermes callbacks; WebUI must show it once.
Some runtimes emit a user-visible progress sentence as a normal token, mirror the
same text through reasoning, and then report it through interim_assistant before
a tool call. The SSE bridge should keep the visible token, suppress the hidden
reasoning echo, and mark interim_assistant as already_streamed so the client and
journal recovery do not append the same paragraph again.
"""
import api.streaming as streaming
progress = "Gefunden: der Skill-Tab lädt `/api/skill-html?slug=...`."
class FakeSession:
def __init__(self):
self.session_id = "issue_progress_echo_dedupe"
self.title = "Progress 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 EchoAgent:
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.tool_progress_callback = cast(Callable[..., None], tool_progress_callback)
self.interim_assistant_callback = cast(Callable[[str], None], interim_assistant_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(progress)
self.reasoning_callback(progress)
self.tool_progress_callback("reasoning.available", "progress", progress, {})
self.interim_assistant_callback(progress)
history = kwargs.get("conversation_history", [])
return {"messages": history + [
{"role": "user", "content": kwargs["persist_user_message"]},
{"role": "assistant", "content": progress},
]}
def interrupt(self, _message):
pass
fake_session = FakeSession()
fake_stream_id = "stream_issue_progress_echo_dedupe"
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=EchoAgent), \
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="scan",
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": progress})
]
assert not [payload for event, payload in events if event == "reasoning" and payload.get("text") == progress]
interim = [payload for event, payload in events if event == "interim_assistant"]
assert interim == [{"text": progress, "already_streamed": True}]
def test_reasoning_then_interim_progress_marks_reasoning_echo(cleanup_test_sessions):
"""A progress sentence mirrored as reasoning first must become prose, not Thinking.
Some reasoning-heavy runtimes emit the same user-facing status sentence first
through the reasoning callback and later through interim_assistant. The first
reasoning SSE may already be in the browser/journal, so the bridge must mark
the interim event and strip the durable reasoning tail before settlement.
"""
import api.streaming as streaming
progress = "我先检查当前仓库状态,然后定位重复渲染路径。"
class FakeSession:
def __init__(self):
self.session_id = "issue_reasoning_interim_echo"
self.title = "Reasoning interim 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 ReasoningThenInterimAgent:
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.reasoning_callback = cast(Callable[[str], None], reasoning_callback)
self.interim_assistant_callback = cast(Callable[[str], None], interim_assistant_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.reasoning_callback(progress)
self.interim_assistant_callback(progress)
history = kwargs.get("conversation_history", [])
return {"messages": history + [
{"role": "user", "content": kwargs["persist_user_message"]},
{"role": "assistant", "content": progress},
]}
def interrupt(self, _message):
pass
fake_session = FakeSession()
fake_stream_id = "stream_issue_reasoning_interim_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=ReasoningThenInterimAgent), \
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="scan",
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)
interim = [payload for event, payload in events if event == "interim_assistant"]
assert interim == [{
"text": progress,
"already_streamed": False,
"reasoning_echo": True,
}]
done_payloads = [payload for event, payload in events if event == "done"]
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 "")