205 lines
8.3 KiB
Python
205 lines
8.3 KiB
Python
# 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"
|