tests: pin ink engine in _make_tui_argv npm-bootstrap tests (post-merge semantic fix)

Main's rewritten test_tui_npm_install.py tests call _make_tui_argv expecting
the Ink/npm flow unconditionally; with the dual-engine dispatch merged in,
_resolve_tui_engine() auto-selects opentui whenever ui-opentui/dist is built
in the repo, routing the call away from the path under test (first subprocess
became 'node --version' instead of 'npm run build'). Pin the engine to ink
via an autouse fixture, mirroring the existing pinning precedent in
test_tui_resume_flow.py.
This commit is contained in:
alt-glitch 2026-06-12 10:32:40 +05:30
parent ab37440ce6
commit e1067dbbe5
756 changed files with 79874 additions and 19585 deletions

View file

@ -339,6 +339,123 @@ class TestCliApprovalUi:
assert not cli._background_tasks
def _make_real_paint_cli_stub():
"""A stub whose modal repaint path runs the REAL _paint_now / _invalidate.
Both gates are set adversarially: _resize_recovery_pending=True and a recent
_last_invalidate inside the throttle window. A throttled _invalidate() would
be dropped under these conditions _paint_now must paint regardless.
"""
cli = HermesCLI.__new__(HermesCLI)
cli._approval_state = None
cli._approval_deadline = 0
cli._approval_lock = threading.Lock()
cli._sudo_state = None
cli._sudo_deadline = 0
cli._clarify_state = None
cli._clarify_freetext = False
cli._clarify_deadline = 0
cli._modal_input_snapshot = None
# Real methods, not mocks.
cli._paint_now = HermesCLI._paint_now.__get__(cli, HermesCLI)
cli._invalidate = HermesCLI._invalidate.__get__(cli, HermesCLI)
cli._resize_recovery_pending = True # gate 1: resize in flight
cli._last_invalidate = time.monotonic() # gate 2: inside throttle window
cli._app = SimpleNamespace(invalidate=MagicMock(), current_buffer=_FakeBuffer())
return cli
class TestModalPaintNow:
"""Regression for #41098 — modal prompts must paint immediately.
The dangerous-command approval, clarify, and sudo prompts run their wait
loop on a background thread, set modal state a ConditionalContainer reads,
then must repaint so the panel becomes visible. They used the throttled
_invalidate(), whose paint is silently dropped on a 250ms window collision
or while a resize is pending so the prompt timed out unseen. They now use
_paint_now(), which paints directly like the modal key-binding handlers.
"""
def test_paint_now_bypasses_throttle_and_resize_guard(self):
cli = _make_real_paint_cli_stub()
# A bare _invalidate() is suppressed under both gates...
cli._invalidate()
assert not cli._app.invalidate.called
# ...but _paint_now() always paints.
cli._paint_now()
assert cli._app.invalidate.called
def test_paint_now_no_app_is_safe(self):
cli = HermesCLI.__new__(HermesCLI)
cli._app = None
cli._paint_now() # must not raise
def _drive(self, cli, target, state_attr):
result = {}
def _run():
result["value"] = target()
with patch.object(cli_module, "_cprint"):
thread = threading.Thread(target=_run, daemon=True)
thread.start()
deadline = time.time() + 2
while getattr(cli, state_attr) is None and time.time() < deadline:
time.sleep(0.01)
assert getattr(cli, state_attr) is not None
assert cli._app.invalidate.called, (
f"{state_attr} panel was not painted despite throttle + resize gates"
)
# Reset so we can prove the response-received teardown also repaints
# (the panel must clear at once, not be held by the throttle).
cli._app.invalidate.reset_mock()
getattr(cli, state_attr)["response_queue"].put(
"deny" if state_attr == "_approval_state" else
("a" if state_attr == "_clarify_state" else "pw")
)
thread.join(timeout=2)
# clarify returns immediately on a response (no teardown repaint);
# approval and sudo repaint to tear the panel down.
if state_attr != "_clarify_state":
assert cli._app.invalidate.called, (
f"{state_attr} panel was not repainted on teardown"
)
assert not thread.is_alive()
return result["value"]
def test_approval_prompt_paints_under_both_gates(self):
cli = _make_real_paint_cli_stub()
value = self._drive(
cli, lambda: cli._approval_callback("rm -rf /tmp/scratch", "danger"),
"_approval_state",
)
assert value == "deny"
def test_clarify_prompt_paints_under_both_gates(self):
cli = _make_real_paint_cli_stub()
value = self._drive(
cli, lambda: cli._clarify_callback("Pick one", ["a", "b"]),
"_clarify_state",
)
assert value == "a"
def test_sudo_prompt_paints_under_both_gates(self):
cli = _make_real_paint_cli_stub()
value = self._drive(cli, cli._sudo_password_callback, "_sudo_state")
assert value == "pw"
def test_secret_response_teardown_paints(self):
"""_submit_secret_response tears the secret panel down via _paint_now,
so the panel clears immediately rather than being held by the throttle."""
cli = _make_real_paint_cli_stub()
cli._secret_state = {"response_queue": queue.Queue()}
cli._secret_deadline = 0
cli._submit_secret_response("hunter2")
assert cli._secret_state is None
assert cli._app.invalidate.called
assert cli._secret_state is None # cleared
class TestApprovalCallbackThreadLocalWiring:
"""Regression guard for the thread-local callback freeze (#13617 / #13618).

View file

@ -238,7 +238,7 @@ class TestChromeDebugLaunch:
cli._pending_input = Queue()
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
with patch("cli.is_browser_debug_ready", return_value=True), \
with patch("hermes_cli.cli_commands_mixin.is_browser_debug_ready", return_value=True), \
patch("tools.browser_tool.cleanup_all_browsers"), \
patch("tools.browser_tool._ensure_cdp_supervisor"), \
redirect_stdout(StringIO()):

View file

@ -679,3 +679,54 @@ class TestStatusBarWidthSource:
mock_get_app.assert_not_called()
mock_shutil.assert_not_called()
assert len(text) > 0
class TestIdleSinceLastTurn:
"""Time-since-last-final-agent-response read-out on the status bar."""
def test_hidden_before_first_turn(self):
assert HermesCLI._format_idle_since(None, turn_live=False) == ""
def test_hidden_while_turn_is_live(self):
assert HermesCLI._format_idle_since(time.time() - 30, turn_live=True) == ""
def test_shows_compact_idle_time_after_turn(self):
label = HermesCLI._format_idle_since(time.time() - 42, turn_live=False)
assert label.startswith("")
assert label == "✓ 42s"
def test_scales_to_minutes(self):
label = HermesCLI._format_idle_since(time.time() - 3 * 60, turn_live=False)
assert label == "✓ 3m"
def test_snapshot_carries_idle_since(self):
cli_obj = _make_cli()
cli_obj._last_turn_finished_at = time.time() - 10
cli_obj._prompt_start_time = None
cli_obj._prompt_duration = 5.0
snapshot = cli_obj._get_status_bar_snapshot()
assert snapshot["idle_since"].startswith("")
def test_snapshot_idle_empty_during_live_turn(self):
cli_obj = _make_cli()
cli_obj._last_turn_finished_at = time.time() - 10
cli_obj._prompt_start_time = time.time()
cli_obj._prompt_duration = 0.0
snapshot = cli_obj._get_status_bar_snapshot()
assert snapshot["idle_since"] == ""
def test_wide_status_bar_text_includes_idle(self):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10_230,
completion_tokens=2_220,
total_tokens=12_450,
api_calls=7,
context_tokens=12_450,
context_length=200_000,
)
cli_obj._last_turn_finished_at = time.time() - 42
cli_obj._prompt_start_time = None
cli_obj._prompt_duration = 7.0
text = cli_obj._build_status_bar_text(width=160)
assert "✓ 42s" in text

View file

@ -0,0 +1,270 @@
from types import SimpleNamespace
import pytest
import cli
@pytest.fixture(autouse=True)
def reset_single_query_finalize_state(monkeypatch):
monkeypatch.setattr(cli, "_single_query_finalize_attempted_session_ids", set())
monkeypatch.setattr(cli, "_cleanup_done", False)
def test_finalize_single_query_runs_cleanup_without_reemitting_finalize_before_release(monkeypatch):
calls = []
fake_cli = SimpleNamespace(_release_active_session=lambda: calls.append(("release", {})))
def cleanup(**kwargs):
calls.append(("cleanup", kwargs))
monkeypatch.setattr(
cli,
"_notify_single_query_session_finalize",
lambda _cli: calls.append(("finalize", {})),
)
monkeypatch.setattr(cli, "_run_cleanup", cleanup)
cli._finalize_single_query(fake_cli)
assert calls == [
("finalize", {}),
("cleanup", {"notify_session_finalize": False}),
("release", {}),
]
def test_finalize_single_query_releases_session_when_cleanup_fails(monkeypatch):
calls = []
fake_cli = SimpleNamespace(_release_active_session=lambda: calls.append("release"))
def cleanup(**kwargs):
calls.append("cleanup")
raise RuntimeError("cleanup failed")
monkeypatch.setattr(
cli,
"_notify_single_query_session_finalize",
lambda _cli: calls.append("finalize"),
)
monkeypatch.setattr(cli, "_run_cleanup", cleanup)
with pytest.raises(RuntimeError, match="cleanup failed"):
cli._finalize_single_query(fake_cli)
assert calls == ["finalize", "cleanup", "release"]
def test_finalize_single_query_runs_cleanup_when_finalize_hook_fails(monkeypatch):
calls = []
fake_agent = SimpleNamespace(session_id="agent-session", platform="cli")
fake_cli = SimpleNamespace(
agent=fake_agent,
session_id="cli-session",
_release_active_session=lambda: calls.append("release"),
)
def invoke_hook(name, **kwargs):
calls.append("finalize")
raise RuntimeError("hook failed")
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", invoke_hook)
monkeypatch.setattr(cli, "_run_cleanup", lambda **kwargs: calls.append("cleanup"))
cli._finalize_single_query(fake_cli)
assert calls == ["finalize", "cleanup", "release"]
def test_finalize_single_query_signal_window_does_not_reemit_during_atexit(monkeypatch):
calls = []
fake_agent = SimpleNamespace(session_id="agent-session", platform="cli")
fake_cli = SimpleNamespace(
agent=fake_agent,
session_id="cli-session",
_release_active_session=lambda: calls.append(("release", {})),
)
def invoke_hook(name, **kwargs):
calls.append((name, kwargs))
def interrupted_cleanup(**_kwargs):
raise KeyboardInterrupt()
expected_finalize = (
"on_session_finalize",
{
"session_id": "agent-session",
"platform": "cli",
"reason": "shutdown",
},
)
original_run_cleanup = cli._run_cleanup
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", invoke_hook)
monkeypatch.setattr(cli, "_run_cleanup", interrupted_cleanup)
with pytest.raises(KeyboardInterrupt):
cli._finalize_single_query(fake_cli)
assert calls == [expected_finalize, ("release", {})]
# Simulate later atexit cleanup after the interrupted one-shot path. The
# active agent may already be unavailable by then.
monkeypatch.setattr(cli, "_run_cleanup", original_run_cleanup)
monkeypatch.setattr(cli, "_active_agent_ref", None)
monkeypatch.setattr(cli, "_reset_terminal_input_modes_on_exit", lambda: None)
monkeypatch.setattr(cli, "_cleanup_all_terminals", lambda: None)
monkeypatch.setattr(cli, "_cleanup_all_browsers", lambda: None)
monkeypatch.setattr("tools.mcp_tool.shutdown_mcp_servers", lambda: None)
monkeypatch.setattr("agent.auxiliary_client.shutdown_cached_clients", lambda: None)
cli._run_cleanup()
assert calls == [expected_finalize, ("release", {})]
def test_notify_single_query_session_finalize_uses_agent_session(monkeypatch):
calls = []
fake_agent = SimpleNamespace(session_id="agent-session", platform="cli")
fake_cli = SimpleNamespace(agent=fake_agent, session_id="cli-session")
def invoke_hook(name, **kwargs):
calls.append((name, kwargs))
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", invoke_hook)
cli._notify_single_query_session_finalize(fake_cli)
assert calls == [
(
"on_session_finalize",
{
"session_id": "agent-session",
"platform": "cli",
"reason": "shutdown",
},
)
]
def test_human_single_query_main_finalizes_after_query(monkeypatch):
calls = []
import cli as cli_mod
class _Console:
def print(self, *_args, **_kwargs):
calls.append("query-label")
class FakeCLI:
def __init__(self, **_kwargs):
self.console = _Console()
self.session_id = "single-query-session"
self.agent = SimpleNamespace(
session_id="single-query-session",
platform="cli",
)
def _claim_active_session(self, surface, *, stderr=False):
calls.append(("claim", surface, stderr))
return True
def _show_security_advisories(self):
calls.append("advisories")
def chat(self, query, images=None):
calls.append(("chat", query, images))
return "done"
def _print_exit_summary(self):
calls.append("summary")
monkeypatch.setattr(cli_mod, "HermesCLI", FakeCLI)
monkeypatch.setattr(cli_mod.atexit, "register", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
cli_mod,
"_finalize_single_query",
lambda fake_cli: calls.append(("finalize", fake_cli.session_id)),
)
cli_mod.main(query="hello", quiet=False, toolsets="terminal")
assert calls == [
("claim", "cli", False),
"query-label",
"advisories",
("chat", "hello", None),
"summary",
("finalize", "single-query-session"),
]
def test_quiet_single_query_main_finalizes_while_preserving_exit_code(monkeypatch):
calls = []
import cli as cli_mod
def run_conversation(*, user_message, conversation_history):
calls.append(("run", user_message, conversation_history))
return {
"final_response": "",
"error": "provider failed",
"failed": True,
}
class FakeCLI:
def __init__(self, **_kwargs):
self.provider = "test-provider"
self.model = "test-model"
self.session_id = "quiet-session"
self.conversation_history = []
self._active_agent_route_signature = "same-route"
self.agent = SimpleNamespace(
session_id="quiet-session",
platform="cli",
quiet_mode=False,
suppress_status_output=False,
stream_delta_callback=object(),
tool_gen_callback=object(),
run_conversation=run_conversation,
)
def _claim_active_session(self, surface, *, stderr=False):
calls.append(("claim", surface, stderr))
return True
def _ensure_runtime_credentials(self):
calls.append("credentials")
return True
def _resolve_turn_agent_config(self, effective_query):
calls.append(("resolve", effective_query))
return {
"signature": "same-route",
"model": None,
"runtime": None,
"request_overrides": None,
}
def _init_agent(self, **kwargs):
calls.append(("init", kwargs))
return True
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
monkeypatch.delenv("HERMES_KANBAN_GOAL_MODE", raising=False)
monkeypatch.setattr(cli_mod, "HermesCLI", FakeCLI)
monkeypatch.setattr(cli_mod.atexit, "register", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
cli_mod,
"_finalize_single_query",
lambda fake_cli: calls.append(("finalize", fake_cli.session_id)),
)
with pytest.raises(SystemExit) as exc_info:
cli_mod.main(query="hello", quiet=True, toolsets="terminal")
assert exc_info.value.code == 1
assert ("claim", "cli", True) in calls
assert ("run", "hello", []) in calls
assert calls[-1] == ("finalize", "quiet-session")

View file

@ -1,29 +1,31 @@
"""Regression tests for issue #30768 and #32383.
"""Regression tests for #30768, #32383, and #33961.
``_prompt_text_input_modal`` uses a queue-based modal that relies on
prompt_toolkit key bindings receiving keyboard events. On Windows the
prompt_toolkit input channel can deadlock when the modal is entered from
the ``process_loop`` daemon thread. The fix falls back to the simpler
``_prompt_text_input`` (stdin-based) prompt on Windows.
``_prompt_text_input_modal`` answers destructive-slash confirmations through a
queue-based modal driven by prompt_toolkit key bindings. When invoked from the
``process_loop`` daemon thread it sets the modal up on the app's event loop via
``call_soon_threadsafe``, so it is safe on every platform including native
Windows (#33961), where the earlier ``sys.platform == "win32"`` → raw ``input()``
fallback deadlocked the daemon thread against prompt_toolkit's stdin ownership.
These tests verify:
1. Windows detection triggers the stdin fallback
2. Non-Windows daemon threads still use the modal via the app loop
3. macOS/Linux main-thread path still uses the modal (no regression)
4. No-app path still uses the stdin fallback (existing behavior)
5. Empty choices returns None (existing behavior)
1. Daemon-thread confirm uses the modal via the app loop on Linux AND native
Windows (#33961) — never the raw stdin fallback, never a hang.
2. Main-thread confirm with a running app uses the modal.
3. The raw stdin fallback is kept ONLY for the safe cases: no running app, and
(on win32, off-thread) a scheduling failure degrades to a clean cancel.
4. Empty choices returns None.
"""
import queue
import sys
import threading
import time
from unittest.mock import MagicMock, patch
import pytest
def _make_cli():
"""Minimal HermesCLI shell exposing prompt/modal helpers."""
"""Minimal HermesCLI shell exposing the prompt/modal helpers."""
import cli as cli_mod
obj = object.__new__(cli_mod.HermesCLI)
@ -37,9 +39,6 @@ def _make_cli():
return obj
# ---------------------------------------------------------------------------
# Sample choices used across tests
# ---------------------------------------------------------------------------
_SAMPLE_CHOICES = [
("once", "Approve Once", "proceed this time only"),
("always", "Always Approve", "proceed and silence this prompt permanently"),
@ -47,119 +46,106 @@ _SAMPLE_CHOICES = [
]
class TestModalWindowsFallback:
"""Windows dead-lock regression tests for _prompt_text_input_modal."""
def _answer_modal_when_open(cli, response, stop=None):
"""Push ``response`` onto the modal's response_queue once it opens.
def test_windows_falls_back_to_stdin(self):
"""On Windows, _prompt_text_input_modal should use _prompt_text_input."""
Gives up after ~2s, or early when ``stop`` is set (the modal will never open,
e.g. a scheduling failure) so degraded-path tests don't wait the full budget.
"""
for _ in range(100):
if stop is not None and stop.is_set():
return
state = cli._slash_confirm_state
if state and "response_queue" in state:
state["response_queue"].put(response)
return
time.sleep(0.02)
def _run_on_daemon(call, cli, *, platform, response, schedule=None):
"""Invoke ``call`` on a daemon thread — as the process_loop does — answering
the modal with ``response`` once it opens.
Returns ``{result, stdin_called, capture, restore}``. ``schedule`` overrides
the ``call_soon_threadsafe`` side effect (default: run the callback inline);
pass a raiser to simulate a scheduling failure. Fails if the worker hangs,
which is the deadlock canary for #33961.
"""
outcome = {"capture": [], "restore": [], "result": None, "stdin_called": False}
done = threading.Event()
def _worker():
try:
with patch.object(sys, "platform", platform), \
patch.object(cli._app.loop, "call_soon_threadsafe", side_effect=schedule or (lambda cb: cb())), \
patch.object(cli, "_prompt_text_input") as mock_stdin, \
patch.object(cli, "_invalidate"), \
patch.object(cli, "_capture_modal_input_snapshot", side_effect=lambda: outcome["capture"].append(1)), \
patch.object(cli, "_restore_modal_input_snapshot", side_effect=lambda: outcome["restore"].append(1)):
outcome["result"] = call()
outcome["stdin_called"] = mock_stdin.called
finally:
done.set()
worker = threading.Thread(target=_worker, daemon=True)
answerer = threading.Thread(target=_answer_modal_when_open, args=(cli, response, done), daemon=True)
answerer.start()
worker.start()
worker.join(timeout=2.0)
answerer.join(timeout=2.0)
assert not worker.is_alive(), "daemon thread hung — modal deadlocked"
return outcome
class TestModal:
"""Behaviour of _prompt_text_input_modal across platforms and threads."""
@pytest.mark.parametrize("platform", ["linux", "win32"])
def test_daemon_thread_uses_modal_via_app_loop(self, platform):
"""Off the process_loop daemon thread, the confirm uses the modal via
call_soon_threadsafe on every platform including native Windows, where
the old win32 early-return deadlocked on raw input() (#33961)."""
cli = _make_cli()
with patch.object(sys, "platform", "win32"), \
patch.object(cli, "_prompt_text_input", return_value="1") as mock_stdin:
result = cli._prompt_text_input_modal(
title="⚠️ /new — destroys conversation state",
outcome = _run_on_daemon(
lambda: cli._prompt_text_input_modal(
title="⚠️ /reset",
detail="This starts a fresh session.",
choices=_SAMPLE_CHOICES,
)
timeout=5,
),
cli,
platform=platform,
response="once",
)
assert outcome["stdin_called"] is False, "must use the modal, not raw input()"
assert outcome["result"] == "once"
assert outcome["capture"] == [1]
assert outcome["restore"] == [1]
assert cli._slash_confirm_state is None
# The stdin-based fallback was used, not the modal queue path.
mock_stdin.assert_called_once_with("Choice [1/2/3]: ")
assert result == "1"
def test_non_main_thread_uses_modal_via_app_loop(self):
"""Off the main thread on Linux, keep the modal path via app-loop setup."""
def test_main_thread_with_app_uses_modal(self):
"""On the main thread with a running app, the queue-based modal is used."""
cli = _make_cli()
result_holder = {}
setup_calls = []
teardown_calls = []
def _call_soon_threadsafe(callback):
callback()
def run_on_daemon():
with patch.object(sys, "platform", "linux"), \
patch.object(cli._app.loop, "call_soon_threadsafe", side_effect=_call_soon_threadsafe), \
patch.object(cli, "_prompt_text_input") as mock_stdin, \
patch.object(cli, "_capture_modal_input_snapshot", side_effect=lambda: setup_calls.append("capture")), \
patch.object(cli, "_restore_modal_input_snapshot", side_effect=lambda: teardown_calls.append("restore")):
result_holder["result"] = cli._prompt_text_input_modal(
title="⚠️ /reset",
detail="This starts a fresh session.",
choices=_SAMPLE_CHOICES,
timeout=5,
)
result_holder["stdin_called"] = mock_stdin.called
def _submit_after_delay():
time.sleep(0.2)
state = cli._slash_confirm_state
if state and "response_queue" in state:
state["response_queue"].put("once")
submitter = threading.Thread(target=_submit_after_delay, daemon=True)
t = threading.Thread(target=run_on_daemon, daemon=True)
submitter.start()
t.start()
t.join(timeout=2.0)
submitter.join(timeout=2.0)
assert not t.is_alive(), "daemon thread hung — modal deadlocked"
assert result_holder["stdin_called"] is False
assert result_holder["result"] == "once"
assert setup_calls == ["capture"]
assert teardown_calls == ["restore"]
def test_main_thread_non_windows_uses_modal(self):
"""On macOS/Linux main thread, the queue-based modal is still used."""
cli = _make_cli()
# We need to simulate the modal receiving a response. We'll patch
# the response_queue to immediately return a value.
with patch.object(sys, "platform", "darwin"), \
patch.object(cli, "_capture_modal_input_snapshot"), \
patch.object(cli, "_restore_modal_input_snapshot"), \
patch.object(cli, "_invalidate"):
# Start the modal in a way that it will receive a response
# immediately via the queue.
original_queue = queue.Queue
original_time = time.monotonic
patch.object(cli, "_invalidate"), \
patch.object(cli, "_prompt_text_input") as mock_stdin:
answerer = threading.Thread(target=_answer_modal_when_open, args=(cli, "once"), daemon=True)
answerer.start()
result = cli._prompt_text_input_modal(
title="⚠️ /new",
detail="This starts a fresh session.",
choices=_SAMPLE_CHOICES,
timeout=5,
)
answerer.join(timeout=2.0)
def _fake_modal_flow(*args, **kwargs):
"""Simulate the modal flow: set state, put response, return."""
# We'll directly test that the modal path is entered by
# checking that _slash_confirm_state was set.
pass
# Since we can't easily mock the internal queue, let's test
# that the modal path is entered by checking that
# _prompt_text_input was NOT called.
with patch.object(cli, "_prompt_text_input") as mock_stdin:
# Set up a response that will be put into the queue
# after the modal starts waiting.
def _submit_after_delay():
time.sleep(0.2)
state = cli._slash_confirm_state
if state and "response_queue" in state:
state["response_queue"].put("once")
submitter = threading.Thread(target=_submit_after_delay, daemon=True)
submitter.start()
result = cli._prompt_text_input_modal(
title="⚠️ /new",
detail="This starts a fresh session.",
choices=_SAMPLE_CHOICES,
timeout=5,
)
submitter.join(timeout=2.0)
# The stdin fallback should NOT have been called.
mock_stdin.assert_not_called()
# The result should be "once" from the simulated modal response.
assert result == "once"
mock_stdin.assert_not_called()
assert result == "once"
def test_no_app_falls_back_to_stdin(self):
"""Without a prompt_toolkit app, always use stdin fallback."""
"""Without a running app (oneshot / non-interactive), use the stdin prompt."""
cli = _make_cli()
cli._app = None
@ -173,78 +159,102 @@ class TestModalWindowsFallback:
mock_stdin.assert_called_once_with("Choice [1/2/3]: ")
assert result == "3"
def test_empty_choices_returns_none(self):
"""Empty choices list should return None without prompting."""
cli = _make_cli()
with patch.object(cli, "_prompt_text_input") as mock_stdin:
result = cli._prompt_text_input_modal(
title="Test",
detail="Test",
choices=[],
)
mock_stdin.assert_not_called()
assert result is None
def test_windows_fallback_does_not_set_modal_state(self):
"""Verify Windows fallback doesn't leave _slash_confirm_state set."""
def test_windows_no_app_falls_back_to_stdin(self):
"""win32 without a running app keeps stdin — the only case where the raw
prompt is safe on Windows, since no app owns the console to deadlock."""
cli = _make_cli()
cli._app = None
with patch.object(sys, "platform", "win32"), \
patch.object(cli, "_prompt_text_input", return_value="1"):
cli._prompt_text_input_modal(
title="⚠️ /reset",
patch.object(cli, "_prompt_text_input", return_value="1") as mock_stdin:
result = cli._prompt_text_input_modal(
title="⚠️ /new — destroys conversation state",
detail="This starts a fresh session.",
choices=_SAMPLE_CHOICES,
)
mock_stdin.assert_called_once_with("Choice [1/2/3]: ")
assert result == "1"
def test_windows_scheduling_failure_clean_cancels(self):
"""win32 off the main thread: if marshaling onto the app loop fails, cancel
cleanly (None) rather than fall to raw input() (which deadlocks on native
Windows) or hang. Asserts the _stdin_fallback guard (#33961)."""
cli = _make_cli()
def _raise(_cb):
raise RuntimeError("loop closed")
outcome = _run_on_daemon(
lambda: cli._prompt_text_input_modal(
title="⚠️ /reset",
detail="This starts a fresh session.",
choices=_SAMPLE_CHOICES,
timeout=5,
),
cli,
platform="win32",
response="once",
schedule=_raise,
)
assert outcome["stdin_called"] is False, "win32 off-thread must NOT call raw input()"
assert outcome["result"] is None
assert cli._slash_confirm_state is None
def test_non_main_thread_modal_clears_state(self):
"""Verify daemon-thread modal teardown does not leave state behind."""
@pytest.mark.parametrize(
"platform, expect_stdin, expect_result",
[("win32", False, None), ("linux", True, "1")],
)
def test_daemon_thread_no_app_loop_uses_fallback(self, platform, expect_stdin, expect_result):
"""Off the daemon thread with no resolvable app loop (``self._app.loop``
is None / raises), the modal can never be scheduled, so the method short-
circuits at the app_loop-is-None site (cli.py ~7260) a distinct path
from a call_soon_threadsafe failure. win32 clean-cancels (None) instead of
deadlocking on raw input(); other platforms keep the stdin prompt."""
cli = _make_cli()
errors = []
cli._app.loop = None # forces app_loop is None, off the main thread
def _call_soon_threadsafe(callback):
callback()
outcome = {"result": None, "stdin_called": False}
done = threading.Event()
def run_on_daemon():
def _worker():
try:
with patch.object(sys, "platform", "linux"), \
patch.object(cli._app.loop, "call_soon_threadsafe", side_effect=_call_soon_threadsafe):
def _submit_after_delay():
time.sleep(0.2)
state = cli._slash_confirm_state
if state and "response_queue" in state:
state["response_queue"].put("cancel")
submitter = threading.Thread(target=_submit_after_delay, daemon=True)
submitter.start()
cli._prompt_text_input_modal(
title="⚠️ /new",
with patch.object(sys, "platform", platform), \
patch.object(cli, "_prompt_text_input", return_value="1") as mock_stdin, \
patch.object(cli, "_invalidate"):
outcome["result"] = cli._prompt_text_input_modal(
title="⚠️ /reset",
detail="This starts a fresh session.",
choices=_SAMPLE_CHOICES,
timeout=5,
)
submitter.join(timeout=2.0)
if cli._slash_confirm_state is not None:
errors.append("_slash_confirm_state should be None")
except Exception as exc:
errors.append(str(exc))
outcome["stdin_called"] = mock_stdin.called
finally:
done.set()
t = threading.Thread(target=run_on_daemon, daemon=True)
t.start()
t.join(timeout=2.0)
assert not errors, f"unexpected errors: {errors}"
worker = threading.Thread(target=_worker, daemon=True)
worker.start()
worker.join(timeout=2.0)
assert not worker.is_alive(), "daemon thread hung — modal deadlocked"
assert outcome["stdin_called"] is expect_stdin
assert outcome["result"] == expect_result
assert cli._slash_confirm_state is None
def test_empty_choices_returns_none(self):
"""Empty choices returns None without prompting."""
cli = _make_cli()
with patch.object(cli, "_prompt_text_input") as mock_stdin:
result = cli._prompt_text_input_modal(title="Test", detail="Test", choices=[])
mock_stdin.assert_not_called()
assert result is None
class TestConfirmDestructiveSlashWindows:
"""Integration-level tests for _confirm_destructive_slash on Windows."""
"""End-to-end _confirm_destructive_slash on the native-Windows daemon thread."""
def test_confirm_destructive_slash_bypasses_modal_on_windows(self):
"""_confirm_destructive_slash should work on Windows via stdin fallback."""
def _make_interactive_cli(self):
cli = _make_cli()
cli.model = "test-model"
cli._agent_running = False
@ -255,37 +265,140 @@ class TestConfirmDestructiveSlashWindows:
cli._pending_tool_info = {}
cli._tool_start_time = 0.0
cli._last_scrollback_tool = ""
return cli
with patch.object(sys, "platform", "win32"), \
patch.object(cli, "_prompt_text_input", return_value="1"), \
patch("cli.load_cli_config", return_value={"approvals": {"destructive_slash_confirm": True}}):
result = cli._confirm_destructive_slash(
"new",
"This starts a fresh session.\nThe current conversation history will be discarded.",
@pytest.mark.parametrize(
"response, expected",
[("once", "once"), ("cancel", None)],
)
def test_confirm_destructive_slash_uses_modal_on_windows(self, response, expected):
"""On native Windows, the bare /new confirm drives the modal (not stdin)
and returns the chosen outcome the bug #33961 froze this path."""
cli = self._make_interactive_cli()
with patch("cli.load_cli_config", return_value={"approvals": {"destructive_slash_confirm": True}}):
outcome = _run_on_daemon(
lambda: cli._confirm_destructive_slash(
"new",
"This starts a fresh session.\nThe current conversation history will be discarded.",
),
cli,
platform="win32",
response=response,
)
assert result == "once"
assert outcome["stdin_called"] is False
assert outcome["result"] == expected
def test_confirm_destructive_slash_cancelled_on_windows(self):
"""Cancellation via stdin fallback works on Windows."""
class TestNativeWindowsNoRawInputDeadlock:
"""Anti-regression guard exercising the REAL ``_prompt_text_input``.
Every other test here mocks ``_prompt_text_input`` away, so they only
assert *routing* (modal vs. stdin) they cannot observe the actual hang
that #33961 was. The historical regression was precisely that
``_prompt_text_input_modal`` delegated to the *real* ``_prompt_text_input``
on native Windows, which on a non-main thread runs a bare ``input()`` that
blocks forever against prompt_toolkit's stdin ownership.
These tests let the real ``_prompt_text_input`` run with a blocking
``input()`` and assert the worker thread never hangs. They fail on the
pre-#33961 code (win32 → ``_prompt_text_input`` → off-main ``input()``)
and pass once the modal path / clean-cancel fallback is in place.
"""
def test_win32_daemon_thread_never_blocks_on_real_input(self):
"""A blocking input() must NOT hang the daemon thread on win32.
Drives the genuine helper chain (no mock of ``_prompt_text_input``)
with ``builtins.input`` patched to block forever. The confirm must
resolve via the app-loop modal (answered on a background thread, as
the real key bindings would) and never sit in ``input()``. On the
pre-#33961 code the win32 early-return routed to the real
``_prompt_text_input`` off-main ``input()`` permanent hang.
"""
cli = _make_cli()
cli.model = "test-model"
cli._agent_running = False
cli._spinner_text = ""
cli._should_exit = False
cli._command_running = False
cli.session_id = "test-session"
cli._pending_tool_info = {}
cli._tool_start_time = 0.0
cli._last_scrollback_tool = ""
cli._app.loop.call_soon_threadsafe = lambda cb: cb()
with patch.object(sys, "platform", "win32"), \
patch.object(cli, "_prompt_text_input", return_value="3"), \
patch("cli.load_cli_config", return_value={"approvals": {"destructive_slash_confirm": True}}):
result = cli._confirm_destructive_slash(
"reset",
"This starts a fresh session.\nThe current conversation history will be discarded.",
)
def _blocking_input(prompt=""): # stands in for "no line ever arrives"
time.sleep(30)
return "1"
# Choice "3" normalizes to "cancel", which returns None.
assert result is None
outcome = {}
done = threading.Event()
def _worker():
try:
with patch.object(sys, "platform", "win32"), \
patch("builtins.input", side_effect=_blocking_input), \
patch.object(cli, "_capture_modal_input_snapshot"), \
patch.object(cli, "_restore_modal_input_snapshot"), \
patch.object(cli, "_invalidate"):
outcome["result"] = cli._prompt_text_input_modal(
title="/new",
detail="destroys conversation state",
choices=_SAMPLE_CHOICES,
timeout=3,
)
finally:
done.set()
worker = threading.Thread(target=_worker, daemon=True)
answerer = threading.Thread(
target=_answer_modal_when_open, args=(cli, "cancel", done), daemon=True
)
answerer.start()
worker.start()
worker.join(timeout=5.0)
answerer.join(timeout=5.0)
assert not worker.is_alive(), (
"daemon thread hung in real input() — native-Windows confirm "
"deadlock regressed (#33961)"
)
# cancel → None; the point is it RETURNED rather than blocking forever.
assert outcome.get("result") in (None, "cancel")
def test_win32_scheduling_failure_cleanly_cancels_no_input(self):
"""If the modal can't be marshaled onto the app loop on native Windows
(scheduling failure) the off-main-thread path must cancel cleanly
NOT fall through to a blocking raw ``input()``.
This is the degraded branch the pre-#33961 code handled with
``return self._prompt_text_input(...)`` (which deadlocks); the fix
returns ``None`` instead.
"""
cli = _make_cli()
def _raise(cb): # call_soon_threadsafe scheduling failure
raise RuntimeError("event loop closed")
cli._app.loop.call_soon_threadsafe = _raise
input_called = {"n": 0}
def _tracking_input(prompt=""):
input_called["n"] += 1
time.sleep(30)
return "1"
outcome = {}
def _worker():
with patch.object(sys, "platform", "win32"), \
patch("builtins.input", side_effect=_tracking_input), \
patch.object(cli, "_invalidate"):
outcome["result"] = cli._prompt_text_input_modal(
title="/new",
detail="destroys conversation state",
choices=_SAMPLE_CHOICES,
timeout=3,
)
worker = threading.Thread(target=_worker, daemon=True)
worker.start()
worker.join(timeout=5.0)
assert not worker.is_alive(), (
"daemon thread hung — win32 scheduling-failure fallback used raw "
"input() instead of cleanly cancelling (#33961)"
)
assert input_called["n"] == 0, "win32 off-thread fallback must not call input()"
assert outcome.get("result") is None