Files
hermes-agent/tests/agent/test_plugin_stream_hooks.py
kshitijk4poor 572446308e fix(agent): feed inline <think> text to the live reasoning pane (#89647)
The desktop/TUI reasoning pane is driven by reasoning.delta via
reasoning_callback; the scrubber-side collector only filled the final
reasoning_content, which extract_reasoning already recovers from the raw
content, so the pane stayed dead.

- Drop the scrubber reasoning collector (_reasoning_parts, reasoning(),
  clear_reasoning(), \x00 sentinel, _THINK_TAG_RE) and its per-request
  reset hook.
- StreamingThinkScrubber.feed() exposes the text it stripped from inside
  think blocks as last_hidden; _fire_stream_delta forwards it through
  _fire_reasoning_delta(inline=True) while no native reasoning delta has
  arrived for this model response (reset per request) — no double
  reasoning. CLI gating is unchanged: its reasoning_callback is None
  unless show_reasoning/verbose.
- _finish_chat_stream fills reasoning_content from the raw content via
  the existing extract_reasoning when no reasoning delta arrived.
- Replace the collector tests with two guards (live forwarding + native
  suppression; _finish_chat_stream fallback), both red on origin/main.

Co-authored-by: SayHell0W0rld <852938468@qq.com>
2026-09-24 22:37:10 +05:30

393 lines
14 KiB
Python

import threading
import time
from types import SimpleNamespace
from unittest.mock import patch
def _agent():
from run_agent import AIAgent
return AIAgent(
api_key="test-key",
base_url="https://openrouter.ai/api/v1",
provider="openrouter",
model="test/model",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
)
def _wait_for(predicate, timeout=1.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return
time.sleep(0.01)
assert predicate()
def _make_stream_chunk(content=None, finish_reason=None):
delta = SimpleNamespace(content=content, reasoning_content=None, reasoning=None, tool_calls=None)
choice = SimpleNamespace(delta=delta, finish_reason=finish_reason)
return SimpleNamespace(choices=[choice], model="test/model")
def _callbacks(callbacks_by_hook):
return lambda name: tuple(callbacks_by_hook.get(name, ()))
def test_stream_delta_plugin_hook_is_queued_off_token_path(monkeypatch):
from agent.plugin_stream_hooks import shutdown_plugin_stream_hook_dispatcher
shutdown_plugin_stream_hook_dispatcher()
calls = []
def on_stream_delta(**kwargs):
time.sleep(0.2)
calls.append(("on_stream_delta", kwargs))
monkeypatch.setattr("hermes_cli.plugins.iter_hook_callbacks", _callbacks({"on_stream_delta": [on_stream_delta]}))
agent = _agent()
started = time.monotonic()
agent._fire_stream_delta("hello")
elapsed = time.monotonic() - started
assert elapsed < 0.05
_wait_for(lambda: calls)
shutdown_plugin_stream_hook_dispatcher()
assert calls[0][0] == "on_stream_delta"
assert calls[0][1]["delta"] == "hello"
assert calls[0][1]["kind"] == "text"
assert calls[0][1]["model"] == "test/model"
assert calls[0][1]["provider"] == "openrouter"
def test_stream_delta_plugin_hook_error_does_not_break_streaming(monkeypatch):
from agent.plugin_stream_hooks import shutdown_plugin_stream_hook_dispatcher
shutdown_plugin_stream_hook_dispatcher()
ui_deltas = []
def on_stream_delta(**_kwargs):
raise RuntimeError("plugin failed")
monkeypatch.setattr("hermes_cli.plugins.iter_hook_callbacks", _callbacks({"on_stream_delta": [on_stream_delta]}))
agent = _agent()
agent.stream_delta_callback = ui_deltas.append
agent._fire_stream_delta("still visible")
shutdown_plugin_stream_hook_dispatcher()
assert ui_deltas == ["still visible"]
def test_stream_hook_queue_drops_oldest_pending_event_when_full(monkeypatch):
from agent.plugin_stream_hooks import enqueue_plugin_stream_hook, shutdown_plugin_stream_hook_dispatcher
shutdown_plugin_stream_hook_dispatcher()
monkeypatch.setattr("agent.plugin_stream_hooks._QUEUE_SIZE", 1)
delivered = []
first_delivered = threading.Event()
release_worker = threading.Event()
def on_stream_delta(**kwargs):
delivered.append(kwargs["delta"])
first_delivered.set()
release_worker.wait(timeout=1.0)
monkeypatch.setattr("hermes_cli.plugins.iter_hook_callbacks", _callbacks({"on_stream_delta": [on_stream_delta]}))
assert enqueue_plugin_stream_hook("on_stream_delta", delta="first") is True
assert first_delivered.wait(timeout=1.0)
assert enqueue_plugin_stream_hook("on_stream_delta", delta="second") is True
assert enqueue_plugin_stream_hook("on_stream_delta", delta="third") is True
release_worker.set()
_wait_for(lambda: "third" in delivered)
shutdown_plugin_stream_hook_dispatcher()
assert delivered == ["first", "third"]
def test_stream_hook_queue_isolated_per_consumer(monkeypatch):
from agent.plugin_stream_hooks import enqueue_plugin_stream_hook, shutdown_plugin_stream_hook_dispatcher
shutdown_plugin_stream_hook_dispatcher()
monkeypatch.setattr("agent.plugin_stream_hooks._QUEUE_SIZE", 1)
slow_delivered = []
fast_delivered = []
slow_started = threading.Event()
release_slow = threading.Event()
def slow_consumer(**kwargs):
slow_delivered.append(kwargs["delta"])
slow_started.set()
release_slow.wait(timeout=1.0)
def fast_consumer(**kwargs):
fast_delivered.append(kwargs["delta"])
monkeypatch.setattr(
"hermes_cli.plugins.iter_hook_callbacks",
_callbacks({"on_stream_delta": [slow_consumer, fast_consumer]}),
)
assert enqueue_plugin_stream_hook("on_stream_delta", delta="first") is True
assert slow_started.wait(timeout=1.0)
_wait_for(lambda: fast_delivered == ["first"])
assert enqueue_plugin_stream_hook("on_stream_delta", delta="second") is True
_wait_for(lambda: fast_delivered == ["first", "second"])
assert enqueue_plugin_stream_hook("on_stream_delta", delta="third") is True
_wait_for(lambda: fast_delivered == ["first", "second", "third"])
release_slow.set()
_wait_for(lambda: "third" in slow_delivered)
shutdown_plugin_stream_hook_dispatcher()
assert slow_delivered == ["first", "third"]
def test_reasoning_stream_delta_plugin_hook_is_opt_in(monkeypatch):
from agent.plugin_stream_hooks import shutdown_plugin_stream_hook_dispatcher
shutdown_plugin_stream_hook_dispatcher()
calls = []
def on_stream_delta(**kwargs):
calls.append(("on_stream_delta", kwargs))
monkeypatch.setattr("hermes_cli.plugins.iter_hook_callbacks", _callbacks({"on_stream_delta": [on_stream_delta]}))
agent = _agent()
agent._fire_reasoning_delta("private chain")
shutdown_plugin_stream_hook_dispatcher()
assert calls == []
# The opt-in is resolved once per stream; a new request picks up the flipped flag.
agent._reset_stream_delivery_tracking()
with patch("hermes_cli.config.cfg_get", return_value=True):
agent._fire_reasoning_delta("visible reasoning")
_wait_for(lambda: calls)
shutdown_plugin_stream_hook_dispatcher()
assert calls[0][0] == "on_stream_delta"
assert calls[0][1]["kind"] == "reasoning"
assert calls[0][1]["delta"] == "visible reasoning"
def test_interim_message_plugin_hook_is_queued(monkeypatch):
from agent.plugin_stream_hooks import shutdown_plugin_stream_hook_dispatcher
shutdown_plugin_stream_hook_dispatcher()
calls = []
def on_interim_message(**kwargs):
calls.append(("on_interim_message", kwargs))
monkeypatch.setattr("hermes_cli.plugins.iter_hook_callbacks", _callbacks({"on_interim_message": [on_interim_message]}))
agent = _agent()
agent._emit_interim_assistant_message({"content": "I will inspect the files first."})
_wait_for(lambda: calls)
shutdown_plugin_stream_hook_dispatcher()
assert calls[0][0] == "on_interim_message"
assert calls[0][1]["text"] == "I will inspect the files first."
assert calls[0][1]["already_streamed"] is False
def test_stream_plugin_hook_counts_as_stream_consumer(monkeypatch):
monkeypatch.setattr("hermes_cli.plugins.iter_hook_callbacks", _callbacks({"on_stream_delta": [lambda **_kwargs: None]}))
agent = _agent()
assert agent._has_stream_consumers() is True
def test_interim_message_plugin_hook_does_not_count_as_stream_consumer(monkeypatch):
monkeypatch.setattr("hermes_cli.plugins.iter_hook_callbacks", _callbacks({"on_interim_message": [lambda **_kwargs: None]}))
agent = _agent()
assert agent._has_stream_consumers() is False
def test_stream_lifecycle_plugin_hooks_are_queued(monkeypatch):
from agent.plugin_stream_hooks import shutdown_plugin_stream_hook_dispatcher
shutdown_plugin_stream_hook_dispatcher()
calls = []
def on_stream_start(**kwargs):
calls.append(("on_stream_start", kwargs))
def on_stream_end(**kwargs):
calls.append(("on_stream_end", kwargs))
monkeypatch.setattr(
"hermes_cli.plugins.iter_hook_callbacks",
_callbacks({"on_stream_start": [on_stream_start], "on_stream_end": [on_stream_end]}),
)
agent = _agent()
agent._emit_stream_start()
agent._emit_stream_end(final_text="done", finished=True, error=None)
_wait_for(lambda: len(calls) == 2)
shutdown_plugin_stream_hook_dispatcher()
# start/end are delivered by separate per-callback workers; cross-hook
# arrival order is not guaranteed. Assert content, not interleaving.
assert sorted(call[0] for call in calls) == ["on_stream_end", "on_stream_start"]
start_call = next(call for call in calls if call[0] == "on_stream_start")
end_call = next(call for call in calls if call[0] == "on_stream_end")
assert start_call[1]["model"] == "test/model"
assert end_call[1]["final_text"] == "done"
assert end_call[1]["finished"] is True
assert end_call[1]["error"] is None
@patch("run_agent.AIAgent._create_request_openai_client")
@patch("run_agent.AIAgent._close_request_openai_client")
def test_chat_completion_stream_emits_lifecycle_hooks(_mock_close, mock_create, monkeypatch):
from agent.plugin_stream_hooks import shutdown_plugin_stream_hook_dispatcher
shutdown_plugin_stream_hook_dispatcher()
calls = []
monkeypatch.setattr(
"hermes_cli.plugins.iter_hook_callbacks",
_callbacks(
{
"on_stream_start": [lambda **kwargs: calls.append(("on_stream_start", kwargs))],
"on_stream_delta": [lambda **kwargs: calls.append(("on_stream_delta", kwargs))],
"on_stream_end": [lambda **kwargs: calls.append(("on_stream_end", kwargs))],
}
),
)
mock_client = SimpleNamespace(
chat=SimpleNamespace(
completions=SimpleNamespace(
create=lambda **_kwargs: iter([
_make_stream_chunk(content="hello "),
_make_stream_chunk(content="world"),
_make_stream_chunk(finish_reason="stop"),
])
)
)
)
mock_create.return_value = mock_client
agent = _agent()
agent.api_mode = "chat_completions"
response = agent._interruptible_streaming_api_call({})
_wait_for(lambda: len(calls) == 4)
shutdown_plugin_stream_hook_dispatcher()
assert response.choices[0].message.content == "hello world"
# The dispatcher runs ONE worker per callback, so ordering is guaranteed
# only per hook, not across hooks: the three callbacks here append from
# three concurrent worker threads. Assert the per-hook contract instead
# of a strict global interleaving (which is racy by design).
names = [call[0] for call in calls]
assert sorted(names) == [
"on_stream_delta",
"on_stream_delta",
"on_stream_end",
"on_stream_start",
]
delta_texts = [call[1]["delta"] for call in calls if call[0] == "on_stream_delta"]
assert delta_texts == ["hello ", "world"] # in-order within the hook
end_call = next(call for call in calls if call[0] == "on_stream_end")
assert end_call[1]["final_text"] == "hello world"
assert end_call[1]["finished"] is True
def test_bedrock_reasoning_delta_reaches_plugin_only_observer(monkeypatch):
from agent.plugin_stream_hooks import shutdown_plugin_stream_hook_dispatcher
shutdown_plugin_stream_hook_dispatcher()
calls = []
def on_stream_delta(**kwargs):
calls.append(kwargs)
monkeypatch.setattr("hermes_cli.plugins.iter_hook_callbacks", _callbacks({"on_stream_delta": [on_stream_delta]}))
monkeypatch.setattr("hermes_cli.config.cfg_get", lambda *_args, **_kwargs: True)
monkeypatch.setattr(
"agent.bedrock_adapter._get_bedrock_runtime_client",
lambda _region: SimpleNamespace(converse_stream=lambda **_kwargs: {"stream": []}),
)
monkeypatch.setattr("agent.bedrock_adapter.is_stale_connection_error", lambda _exc: False)
monkeypatch.setattr("agent.bedrock_adapter.is_streaming_access_denied_error", lambda _exc: False)
monkeypatch.setattr("agent.bedrock_adapter.invalidate_runtime_client", lambda *_args, **_kwargs: None)
def stream_converse_with_callbacks(
_raw_response,
*,
on_text_delta=None,
on_tool_start=None,
on_reasoning_delta=None,
on_interrupt_check=None,
on_event=None,
**_kwargs,
):
# Main's Bedrock path also invokes this as a Relay finalizer with the
# intercepted-event replay; only the live pass wires callbacks.
if on_reasoning_delta is not None:
assert on_tool_start is not None
assert on_interrupt_check() is False
on_reasoning_delta("bedrock reasoning")
return SimpleNamespace(choices=[], usage=None, stop_reason="end_turn")
monkeypatch.setattr("agent.bedrock_adapter.stream_converse_with_callbacks", stream_converse_with_callbacks)
agent = _agent()
agent.api_mode = "bedrock_converse"
agent.reasoning_callback = None
agent.stream_delta_callback = None
agent._interruptible_streaming_api_call({"__bedrock_region__": "us-east-1", "__bedrock_converse__": True})
_wait_for(lambda: calls)
shutdown_plugin_stream_hook_dispatcher()
assert calls[0]["kind"] == "reasoning"
assert calls[0]["delta"] == "bedrock reasoning"
def test_inline_think_reaches_reasoning_pane_unless_native_reasoning_streamed():
"""#89647: inline <think> text stripped from content feeds reasoning_callback (the live pane), but not
once the provider streamed native reasoning for this response (no double reasoning)."""
agent = _agent()
seen = []
agent.reasoning_callback = seen.append
agent._reset_stream_delivery_tracking()
for delta in ["<think>", "Let me", " check config", "</think>", "The answer is 42."]:
agent._fire_stream_delta(delta)
assert "".join(seen) == "Let me check config"
seen.clear()
agent._reset_stream_delivery_tracking()
agent._fire_reasoning_delta("native")
agent._fire_stream_delta("<think>dup</think>ok")
assert seen == ["native"]
def test_finish_chat_stream_recovers_inline_reasoning_content():
"""#89647: with no reasoning delta, reasoning_content comes from the <think> blocks in raw content."""
from agent import chat_completion_helpers as cch
call = cch._StreamingCall.__new__(cch._StreamingCall)
call.agent = _agent()
deltas = ["<think>", "Let me", " check config", "</think>", "The answer is 42."]
resp = call._finish_chat_stream(None, "assistant", deltas, [], {}, "stop", "MiniMax-M3", None,
flush_pending=lambda: None)
assert resp.choices[0].message.reasoning_content == "Let me check config"