band-aider-pub/tests/ui/test_human_intervention.py
2026-10-17 11:57:37 -07:00

205 lines
8.3 KiB
Python
Raw Permalink 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.

# SPDX-FileCopyrightText: 2026 Art Sea LLC
#
# SPDX-License-Identifier: AGPL-3.0-only
"""Human dictation and clipboard behavior without an interactive terminal."""
import curses
from dataclasses import replace
from io import StringIO
from unittest.mock import patch
import pytest
from band_aider.ui import escalation_ui
from band_aider.ui.clipboard import Clipboard
from band_aider.ui.dto.human_intervention import HumanInterventionRequestDTO
class _Clipboard(Clipboard):
"""Keep a complete clipboard in memory for deterministic human UI scenarios."""
def __init__(self, value: str) -> None:
"""Seed text that includes whitespace and Unicode."""
self.value = value
def copy(self, text: str) -> str:
"""Store the full copied content."""
self.value = text
return "copied"
def paste(self) -> tuple[str | None, str]:
"""Return the exact seeded clipboard text."""
return self.value, "pasted"
class _Screen:
"""Capture content and panel titles without allocating curses windows."""
def __init__(self, height: int = 32, width: int = 140) -> None:
"""Allocate independent drawing evidence."""
self.height, self.width = height, width
self.text: list[str] = []
self.panels: list[tuple[int, int, int, int]] = []
def getmaxyx(self) -> tuple[int, int]:
"""Return fixed terminal dimensions."""
return self.height, self.width
def addstr(self, y: int, x: int, text: str, attr: int = 0) -> None:
"""Retain displayed text independently of position."""
self.text.append(text)
def derwin(self, height: int, width: int, y: int, x: int) -> "_Screen":
"""Record each boxed content panel."""
self.panels.append((height, width, y, x))
return self
def erase(self) -> None:
"""Leave retained draw evidence available to the test."""
def border(self) -> None:
"""Avoid host curses drawing."""
def refresh(self) -> None:
"""Avoid host terminal refresh."""
def _request(mode: str) -> HumanInterventionRequestDTO:
"""Provide a raw response shape and a model-output sentinel."""
return HumanInterventionRequestDTO(
request_id="human-1", check_name="Acceptance", original_prompt='Return {"outcome":"pass","reason":["return 2"]}',
repeated_response_tail="MODEL_OUTPUT_MUST_NOT_APPEAR_IN_DIRECT", request_md5="request", response_md5="response",
local_model="local", escalated_model="cloud", mode=mode, draft="previous draft", validation_errors="reason[0]: invalid citation",
)
@pytest.mark.parametrize("width", (80, 140))
def test_direct_screen_has_two_stacked_panels_and_raw_json_examples(width: int) -> None:
"""Human dictation sees the actual response schema, no model-output panel, and retained draft."""
request = _request("direct_response")
screen = _Screen(width=width)
state = escalation_ui.EscalationUIState(mode=request.mode, focus_index=1, guidance=request.draft)
escalation_ui.draw_escalation_screen(screen, 32, width, request, None, state)
text = "\n".join(screen.text)
assert len(screen.panels) == 2
assert all(map(lambda panel: panel[3] == 0, screen.panels))
assert '"outcome":"pass"' in text
assert " ASK " in text
assert " YOUR RESPONSE " in text
assert "previous draft" in text
assert "reason[0]: invalid citation" in text
assert "MODEL_OUTPUT_MUST_NOT_APPEAR_IN_DIRECT" not in text
assert "F3 Copy All" in text
assert "F4 Paste" in text
@pytest.mark.parametrize("mode", ("hint", "direct_response"))
def test_copy_all_includes_offscreen_data_and_paste_replaces_selected_input_without_sending(mode: str) -> None:
"""Both human screens use the full panel data and preserve exact pasted Unicode text."""
request = replace(_request(mode), original_prompt="ask\n" * 100 + "OFFSCREEN")
clipboard = _Clipboard(" α\n\n\treturn 2\n")
state = escalation_ui.EscalationUIState(mode=mode, focus_index=1 if mode == "direct_response" else 2, guidance="old", clipboard=clipboard)
response, _ = escalation_ui.handle_escalation_key(1, request, state)
assert response is None
response, _ = escalation_ui.handle_escalation_key(curses.KEY_F4, request, state)
assert response is None
assert state.guidance == " α\n\n\treturn 2\n"
escalation_ui.handle_escalation_key(curses.KEY_F3, request, state)
assert "OFFSCREEN" in clipboard.value
assert state.guidance in clipboard.value
assert ("MODEL_OUTPUT_MUST_NOT_APPEAR_IN_DIRECT" in clipboard.value) is (mode == "hint")
response, _ = escalation_ui.handle_escalation_key(curses.KEY_F8, request, state)
assert response is not None
assert response.guidance == " α\n\n\treturn 2\n"
def test_direct_mouse_send_and_unicode_editing_share_keyboard_semantics() -> None:
"""Buttons and typing preserve the same exact dictated draft."""
request = _request("direct_response")
state = escalation_ui.EscalationUIState(mode=request.mode, focus_index=1)
escalation_ui.handle_escalation_key("λ", request, state)
escalation_ui.handle_escalation_key("\n", request, state)
escalation_ui.handle_escalation_key("返", request, state)
screen = _Screen()
escalation_ui.draw_escalation_screen(screen, 32, 140, request, None, state)
y, x, _, _ = state.regions["send"]
response, _ = escalation_ui.handle_escalation_mouse(x, y, request, state)
assert response is not None
assert response.guidance == "λ\n返"
@pytest.mark.parametrize("mode", ("hint", "direct_response"))
def test_plain_cli_accepts_blank_lines_and_clipboard_content_until_explicit_submit(mode: str) -> None:
"""A clipboard paste and blank lines cannot implicitly send a human response."""
request = replace(_request(mode), draft="")
commands = iter(("first", "", " second", "/select-all", "/paste", "/submit"))
clipboard = _Clipboard(' {\n\t"reason": ["λ"]\n}\n')
response = escalation_ui.read_plain_intervention(request, lambda _: next(commands), StringIO(), clipboard)
assert response.action == "resend"
assert response.guidance == clipboard.value
assert response.guidance.endswith("\n")
@pytest.mark.parametrize("command,action", (("/abort", "abort"), ("/defer", "defer"), ("/suppress", "suppress")))
def test_plain_cli_control_commands_never_submit_draft(command: str, action: str) -> None:
"""Abort, deferral, and suppression remain distinct from submitting a response."""
response = escalation_ui.read_plain_intervention(_request("direct_response"), lambda _: command, StringIO())
assert response.action == action
@pytest.mark.parametrize("mode", ("hint", "direct_response"))
def test_f9_suppresses_intervention_without_submitting_human_text(mode: str) -> None:
"""A suppression hotkey preserves the draft as data and returns control to model work."""
request = _request(mode)
state = escalation_ui.EscalationUIState(mode=mode, guidance="unfinished draft")
response, message = escalation_ui.handle_escalation_key(curses.KEY_F9, request, state)
assert response is not None
assert response.action == "suppress"
assert response.guidance == "unfinished draft"
assert message == "intervention suppressed; continuing models"
def test_clipboard_absence_keeps_draft_and_reports_terminal_paste_option() -> None:
"""Unavailable host tools never erase the human's current response."""
state = escalation_ui.EscalationUIState(mode="direct_response", focus_index=1, guidance="retain me")
with patch("band_aider.ui.clipboard.shutil.which", return_value=None):
message = escalation_ui.clipboard_action("paste", _request("direct_response"), state)
assert state.guidance == "retain me"
assert "terminal paste" in message
def test_terminal_paste_keeps_tabs_blank_lines_and_control_text_in_the_draft() -> None:
"""Bracketed paste cannot turn a tab into focus navigation or an embedded key into Send."""
request = _request("direct_response")
state = escalation_ui.EscalationUIState(mode=request.mode, focus_index=1, pasting=True)
pasted = '\tλ\n\n/submit\n'
responses = tuple(map(lambda key: escalation_ui.handle_escalation_key(key, request, state)[0], pasted + "\x1b[201~"))
assert not any(responses)
assert state.guidance == pasted
assert not state.pasting
assert state.focused_field() == "hint"