Independent review of #120220 found restored tests that could pass without reaching the code they name. Each fix below was A/B'd against a sabotaged production line (red) and the real one (green). - test_clarify_progress_leak: the fake agent slept 2x0.35 s as its only sync. It now waits (<= 5 s) for the progress task's first bubble; the queue is FIFO, so a leaked clarify bubble is what lands first. Reverting the clarify skip in run_turn_runner goes red on the leaked JSON. - test_desktop_lifecycle_windows_live holder-scan case: the kanban row was rebuilt after the serve kill, re-reading the dead pid; NoSuchProcess was swallowed into an empty scan and returned False without reaching the classifier. Rows are now snapshotted while both processes live; a classifier that accepts everything goes red (the old version stayed green). - *_windows_live (control socket, desktop lifecycle, plan reconciliation, shim fail-closed): fixed time.sleep settles become bounded waits on the real condition (argv visible to psutil, pipe gone, handle released) and child readline() calls get a 60 s bound. - test_gateway_proc_fallback BSD ps case: drop the is_windows stub and mark it linux_only like its neighbour; a revert to `ps -A eww` goes red. - test_delivery_ledger_single_connection counts only connections to the ledger's own database, not every sqlite3.connect in the process; pruning on a second connection goes red. - test_codex_models: the hardcoded gpt-5.6 list becomes a relationship to DEFAULT_CODEX_MODELS and the forward-compat table; adding a -pro slug to either goes red. - group-chat-view inline-code: the renderer stub wrapped the code in .aui-md, which the generic inline-code rule themes on its own, so the room's data-slot rule was never tested. Without the wrapper, renaming the room's data-slot goes red.
176 lines
6.5 KiB
Python
176 lines
6.5 KiB
Python
"""Regression tests for #52374 — raw clarify tool-call JSON must never leak
|
|
into the chat as a tool-progress bubble.
|
|
|
|
The adapter's ``send_clarify`` is the user-facing rendering of a clarify
|
|
prompt (interactive buttons, or the numbered-text fallback). The gateway's
|
|
tool-progress callback used to also render a progress bubble for the
|
|
``clarify`` tool.started event — in verbose mode that bubble contains the raw
|
|
tool-call args JSON (``{"question": ..., "choices": [...]}``), and because the
|
|
progress queue drains on a background task the JSON landed right underneath
|
|
the rendered interactive prompt on Slack.
|
|
"""
|
|
|
|
import importlib
|
|
import sys
|
|
import threading
|
|
import types
|
|
|
|
import pytest
|
|
|
|
from gateway.config import Platform, PlatformConfig
|
|
from gateway.platforms.base import BasePlatformAdapter, SendResult
|
|
from gateway.session import SessionSource
|
|
|
|
|
|
class ProgressCaptureAdapter(BasePlatformAdapter):
|
|
"""Records every send so the test can assert nothing leaked."""
|
|
|
|
def __init__(self, platform=Platform.SLACK):
|
|
super().__init__(PlatformConfig(enabled=True, token="***"), platform)
|
|
self.sent = []
|
|
self.edits = []
|
|
# Set on the first send/edit. The fake agent (on an executor thread)
|
|
# waits on it, so the turn cannot end before the progress task has
|
|
# rendered its first bubble.
|
|
self.delivered = threading.Event()
|
|
|
|
def _record(self, content):
|
|
self.delivered.set()
|
|
|
|
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
|
return True
|
|
|
|
async def disconnect(self) -> None:
|
|
return None
|
|
|
|
async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult:
|
|
self.sent.append({"chat_id": chat_id, "content": content})
|
|
self._record(content)
|
|
return SendResult(success=True, message_id="m-1")
|
|
|
|
async def edit_message(self, chat_id, message_id, content) -> SendResult:
|
|
self.edits.append({"chat_id": chat_id, "message_id": message_id, "content": content})
|
|
self._record(content)
|
|
return SendResult(success=True, message_id=message_id)
|
|
|
|
async def send_typing(self, chat_id, metadata=None) -> None:
|
|
return None
|
|
|
|
async def stop_typing(self, chat_id) -> None:
|
|
return None
|
|
|
|
async def get_chat_info(self, chat_id: str):
|
|
return {"id": chat_id}
|
|
|
|
|
|
class ClarifyThenToolAgent:
|
|
"""Emits a clarify tool.started (with raw args) then a normal tool, and
|
|
returns only once the progress task has delivered its first bubble.
|
|
|
|
Both events are queued before that wait, and the queue drains FIFO, so the
|
|
first bubble is the clarify one if clarify leaks and the terminal one if
|
|
not. A timeout means nothing drained and fails the test.
|
|
"""
|
|
|
|
adapter = None
|
|
|
|
def __init__(self, **kwargs):
|
|
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None):
|
|
cb = self.tool_progress_callback
|
|
if cb is not None:
|
|
cb(
|
|
"tool.started",
|
|
"clarify",
|
|
"Which environment?",
|
|
{"question": "Which environment?", "choices": ["staging", "production"]},
|
|
)
|
|
cb("tool.started", "terminal", "pwd", {})
|
|
if not type(self).adapter.delivered.wait(timeout=5.0):
|
|
raise AssertionError("progress task never delivered a bubble")
|
|
return {"final_response": "done", "messages": [], "api_calls": 1}
|
|
|
|
|
|
def _make_runner(adapter):
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
GatewayRunner = gateway_run.GatewayRunner
|
|
runner = object.__new__(GatewayRunner)
|
|
runner.adapters = {adapter.platform: adapter}
|
|
runner._voice_mode = {}
|
|
runner._prefill_messages = []
|
|
runner._ephemeral_system_prompt = ""
|
|
runner._reasoning_config = None
|
|
runner._provider_routing = {}
|
|
runner._fallback_model = None
|
|
runner._session_db = None
|
|
runner._running_agents = {}
|
|
runner._session_run_generation = {}
|
|
runner.hooks = types.SimpleNamespace(loaded_hooks=False)
|
|
runner.config = types.SimpleNamespace(
|
|
thread_sessions_per_user=False,
|
|
group_sessions_per_user=False,
|
|
stt_enabled=False,
|
|
)
|
|
return runner
|
|
|
|
|
|
def _install_fakes(monkeypatch, mode):
|
|
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", mode)
|
|
|
|
fake_dotenv = types.ModuleType("dotenv")
|
|
fake_dotenv.load_dotenv = lambda *a, **k: None
|
|
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
|
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
fake_run_agent.AIAgent = ClarifyThenToolAgent
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
import tools.terminal_tool # noqa: F401 — register terminal emoji
|
|
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
|
return gateway_run
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["verbose", "all"])
|
|
@pytest.mark.asyncio
|
|
async def test_clarify_tool_never_renders_progress_bubble(monkeypatch, tmp_path, mode):
|
|
"""No progress bubble for clarify — in any mode, especially verbose.
|
|
|
|
Verbose mode used to dump the raw args JSON
|
|
(``{"question": ..., "choices": [...]}``) into the chat right under the
|
|
interactive prompt (#52374).
|
|
"""
|
|
adapter = ProgressCaptureAdapter()
|
|
runner = _make_runner(adapter)
|
|
monkeypatch.setattr(ClarifyThenToolAgent, "adapter", adapter)
|
|
gateway_run = _install_fakes(monkeypatch, mode)
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
|
|
source = SessionSource(platform=Platform.SLACK, chat_id="C1", chat_type="dm")
|
|
|
|
result = await runner._run_agent(
|
|
message="hello",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="sess-clarify-leak",
|
|
session_key="agent:main:slack:dm:C1",
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
all_content = "\n".join(
|
|
[m["content"] for m in adapter.sent] + [e["content"] for e in adapter.edits]
|
|
)
|
|
# Raw clarify args JSON must not leak anywhere.
|
|
assert '"question"' not in all_content
|
|
assert '"choices"' not in all_content
|
|
assert "Which environment?" not in all_content
|
|
# No clarify progress line at all (verb "Asking" / tool name).
|
|
assert "clarify" not in all_content
|
|
assert "Asking" not in all_content
|
|
# The unrelated terminal tool still renders progress normally (and proves
|
|
# the no-leak asserts above ran against a drained queue).
|
|
assert "pwd" in all_content
|