260 lines
9.4 KiB
Python
260 lines
9.4 KiB
Python
"""Behavioral I/O tests for :class:`TerminalRegistry` against a real tmux.
|
|
|
|
These complement :mod:`tests.terminals.test_registry` (registry
|
|
lifecycle, no keystrokes) and :mod:`tests.tools.builtins.test_sys_terminal`
|
|
(the same behaviors through the ``sys_terminal_*`` tool envelopes) by
|
|
driving ``TerminalRegistry.launch`` → ``TerminalInstance.send`` / ``.read``
|
|
directly: interactive state that survives across calls, cwd anchoring of
|
|
the live shell, control-key delivery, and per-session isolation.
|
|
|
|
End-to-end coverage of the same capability would need a live runner and a
|
|
real LLM. These reach the same tmux behaviors with neither, so the
|
|
capability keeps coverage in the ``tests/terminals`` CI shard (which
|
|
installs tmux).
|
|
|
|
Skipped when tmux is absent. ``send`` is asynchronous from the shell's
|
|
view, so reads poll on a bounded budget rather than asserting a single
|
|
capture.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import shutil
|
|
from collections.abc import AsyncIterator
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec, TerminalEnvSpec
|
|
from omnigent.inner.terminal import TerminalInstance
|
|
from omnigent.terminals import TerminalRegistry
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
shutil.which("tmux") is None,
|
|
reason="tmux not installed; registry I/O tests need a real tmux on PATH",
|
|
)
|
|
|
|
_MARKER_BUDGET_S = 5.0
|
|
_POLL_INTERVAL_S = 0.1
|
|
|
|
|
|
def _dewrap(screen: str) -> str:
|
|
"""Join the pane's ``-x 80`` soft-wrapped rows so a needle straddling the wrap matches."""
|
|
return screen.replace("\n", "")
|
|
|
|
|
|
def _echo_only_on_run(marker: str) -> str:
|
|
"""An ``echo`` whose *typed* form can't contain *marker* — only its output can.
|
|
|
|
The empty ``""`` splits the literal in the keystroke echo (and in any
|
|
``_dewrap`` join of the wrapped command line), so a needle search proves
|
|
the command produced output rather than merely that it was typed.
|
|
"""
|
|
mid = len(marker) // 2
|
|
return f'echo {marker[:mid]}""{marker[mid:]}'
|
|
|
|
|
|
def _bash_spec(cwd: Path, *, allow_cwd_override: bool = False) -> TerminalEnvSpec:
|
|
return TerminalEnvSpec(
|
|
command="bash",
|
|
allow_cwd_override=allow_cwd_override,
|
|
os_env=OSEnvSpec(
|
|
type="caller_process",
|
|
cwd=str(cwd),
|
|
sandbox=OSEnvSandboxSpec(type="none"),
|
|
),
|
|
)
|
|
|
|
|
|
async def _read_until(
|
|
instance: TerminalInstance,
|
|
needle: str,
|
|
*,
|
|
budget_s: float = _MARKER_BUDGET_S,
|
|
) -> str:
|
|
"""Poll ``instance.read`` until *needle* appears or the budget elapses.
|
|
|
|
Returns the last pane text seen — containing *needle* on success, or
|
|
the final capture (for a useful failure message) on timeout.
|
|
"""
|
|
waited = 0.0
|
|
screen = ""
|
|
while waited < budget_s:
|
|
screen = _dewrap((await instance.read()).get("screen", ""))
|
|
if needle in screen:
|
|
return screen
|
|
await asyncio.sleep(_POLL_INTERVAL_S)
|
|
waited += _POLL_INTERVAL_S
|
|
return screen
|
|
|
|
|
|
def _path_tail(*parts: str) -> str:
|
|
"""Join path segments into a tmux-pwd needle.
|
|
|
|
Matching a two-segment tail (parent/leaf) rather than a bare leaf
|
|
keeps the assertion off a basename-only shell prompt and off the
|
|
macOS ``/var`` → ``/private/var`` symlink rewrite, both of which
|
|
would otherwise let a test pass without the real pwd in the pane.
|
|
"""
|
|
return str(Path(*parts))
|
|
|
|
|
|
@pytest.fixture
|
|
def reg() -> TerminalRegistry:
|
|
return TerminalRegistry()
|
|
|
|
|
|
@pytest.fixture
|
|
async def shutdown_terminals(reg: TerminalRegistry) -> AsyncIterator[None]:
|
|
"""Close every terminal at test exit, even when an assertion fails."""
|
|
yield
|
|
await reg.shutdown()
|
|
|
|
|
|
async def test_shell_state_persists_across_separate_sends(
|
|
reg: TerminalRegistry, shutdown_terminals: None, tmp_path: Path
|
|
) -> None:
|
|
"""A variable set in one ``send`` is still set in a later ``send``.
|
|
|
|
The capability ``sys_terminal_*`` adds over a one-shot ``sys_os_shell``:
|
|
one long-lived shell across calls. A "fresh shell per send" regression
|
|
passes every lifecycle test yet fails here.
|
|
"""
|
|
instance = await reg.launch("conv_a", "bash", "s1", _bash_spec(tmp_path))
|
|
|
|
await instance.send(text="MARKER_VAR=persisted_value", keys="Enter")
|
|
await instance.send(text="echo VAR_IS_$MARKER_VAR", keys="Enter")
|
|
|
|
screen = await _read_until(instance, "VAR_IS_persisted_value")
|
|
assert "VAR_IS_persisted_value" in screen, (
|
|
"variable from the first send was not visible in the second — each "
|
|
f"send is spawning a fresh shell. Last pane:\n{screen!r}"
|
|
)
|
|
|
|
|
|
async def test_working_directory_change_persists_across_sends(
|
|
reg: TerminalRegistry, shutdown_terminals: None, tmp_path: Path
|
|
) -> None:
|
|
"""A ``cd`` in one send is reflected by ``pwd`` in a later send."""
|
|
subdir = tmp_path / "nested_dir"
|
|
subdir.mkdir()
|
|
instance = await reg.launch("conv_a", "bash", "s1", _bash_spec(tmp_path))
|
|
|
|
await instance.send(text="cd nested_dir", keys="Enter")
|
|
await instance.send(text="pwd", keys="Enter")
|
|
|
|
needle = _path_tail(tmp_path.name, "nested_dir")
|
|
screen = await _read_until(instance, needle)
|
|
assert needle in screen, (
|
|
f"cd from the first send did not persist into the second send's pwd. "
|
|
f"Last pane:\n{screen!r}"
|
|
)
|
|
|
|
|
|
async def test_launched_shell_starts_in_spec_cwd(
|
|
reg: TerminalRegistry, shutdown_terminals: None, tmp_path: Path
|
|
) -> None:
|
|
"""``pwd`` in a freshly launched shell reports the spec's cwd.
|
|
|
|
The behavioral half of ``_resolve_cwd``'s precedence logic, which is
|
|
unit-tested in isolation but never proven against a live shell.
|
|
"""
|
|
instance = await reg.launch("conv_a", "bash", "s1", _bash_spec(tmp_path))
|
|
|
|
await instance.send(text="pwd", keys="Enter")
|
|
|
|
needle = _path_tail(tmp_path.parent.name, tmp_path.name)
|
|
screen = await _read_until(instance, needle)
|
|
assert needle in screen, (
|
|
f"launched shell's pwd did not report the spec cwd {tmp_path}. Last pane:\n{screen!r}"
|
|
)
|
|
|
|
|
|
async def test_cwd_override_anchors_live_shell_in_subdirectory(
|
|
reg: TerminalRegistry, shutdown_terminals: None, tmp_path: Path
|
|
) -> None:
|
|
"""A per-launch ``cwd_override`` starts the live shell in that subdir."""
|
|
override_dir = tmp_path / "workdir"
|
|
override_dir.mkdir()
|
|
|
|
instance = await reg.launch(
|
|
"conv_a",
|
|
"bash",
|
|
"s1",
|
|
_bash_spec(tmp_path, allow_cwd_override=True),
|
|
cwd_override=str(override_dir),
|
|
)
|
|
|
|
assert instance.launch_cwd is not None
|
|
assert Path(instance.launch_cwd).name == "workdir", (
|
|
f"launch_cwd did not reflect the cwd_override; got {instance.launch_cwd!r}"
|
|
)
|
|
|
|
await instance.send(text="pwd", keys="Enter")
|
|
needle = _path_tail(tmp_path.name, "workdir")
|
|
screen = await _read_until(instance, needle)
|
|
assert needle in screen, f"cwd_override did not anchor the live shell. Last pane:\n{screen!r}"
|
|
|
|
|
|
async def test_ctrl_c_interrupts_running_command(
|
|
reg: TerminalRegistry, shutdown_terminals: None, tmp_path: Path
|
|
) -> None:
|
|
"""``keys="C-c"`` interrupts a running foreground command.
|
|
|
|
Affirmative, not merely "the prompt recovered": C-c is sent only after
|
|
the foreground job's own output proves it is executing (not still at an
|
|
empty prompt), the sleep is long enough that an ineffective C-c leaves
|
|
the recovery echo queued behind it past the poll budget, and the job's
|
|
post-sleep marker must be absent — so a no-op C-c fails the test.
|
|
"""
|
|
instance = await reg.launch("conv_a", "bash", "s1", _bash_spec(tmp_path))
|
|
|
|
running = _echo_only_on_run("FOREGROUND_RUNNING")
|
|
not_interrupted = _echo_only_on_run("SLEEP_FINISHED_NOT_INTERRUPTED")
|
|
await instance.send(text=f"{running} && sleep 120 && {not_interrupted}", keys="Enter")
|
|
|
|
started = await _read_until(instance, "FOREGROUND_RUNNING")
|
|
assert "FOREGROUND_RUNNING" in started, (
|
|
"foreground job never started, so C-c would land on an empty prompt and "
|
|
f"prove nothing. Last pane:\n{started!r}"
|
|
)
|
|
|
|
interrupt = await instance.send(text=None, keys="C-c")
|
|
assert interrupt.get("status") == "sent", f"C-c send failed: {interrupt!r}"
|
|
|
|
await instance.send(text=_echo_only_on_run("INTERRUPT_RECOVERED_OK"), keys="Enter")
|
|
screen = await _read_until(instance, "INTERRUPT_RECOVERED_OK")
|
|
assert "INTERRUPT_RECOVERED_OK" in screen, (
|
|
"recovery echo never ran, so the foreground `sleep` was not interrupted "
|
|
f"and still holds the shell. Last pane:\n{screen!r}"
|
|
)
|
|
assert "SLEEP_FINISHED_NOT_INTERRUPTED" not in screen, (
|
|
"the sleep ran to completion, so C-c did not interrupt the foreground "
|
|
f"command list. Last pane:\n{screen!r}"
|
|
)
|
|
|
|
|
|
async def test_send_and_read_after_close_report_not_running(
|
|
reg: TerminalRegistry, shutdown_terminals: None, tmp_path: Path
|
|
) -> None:
|
|
"""Once closed, the instance's ``send`` / ``read`` error cleanly.
|
|
|
|
``test_registry.py`` proves ``close`` removes the registry entry; this
|
|
proves the instance refuses I/O afterward instead of talking to a dead
|
|
socket.
|
|
"""
|
|
instance = await reg.launch("conv_a", "bash", "s1", _bash_spec(tmp_path))
|
|
alive = await instance.is_alive()
|
|
assert alive
|
|
|
|
close_result = await reg.close("conv_a", "bash", "s1")
|
|
assert close_result is True
|
|
assert instance.running is False
|
|
alive_after_close = await instance.is_alive()
|
|
assert alive_after_close is False
|
|
|
|
send_result = await instance.send(text="echo too_late", keys="Enter")
|
|
assert "error" in send_result
|
|
assert "error" in await instance.read()
|