fix: restore interactive PTY behavior
Some checks failed
tamq-ci / test (push) Failing after 6s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a03397-4d51-7fd1-8ff2-946eb22ea2bc
This commit is contained in:
tegwick 2026-08-24 19:28:49 +02:00
parent 397a2b4d58
commit 68475922bb
15 changed files with 437 additions and 50 deletions

View file

@ -57,8 +57,9 @@ def test_detached_no_service_start_skips_socket_registration(monkeypatch, capsys
assert command == "codex"
return "plan"
def ensure_plan(self, plan):
def ensure_plan(self, plan, *, tap=True):
assert plan == "plan"
assert tap is False
return endpoint
monkeypatch.setattr("tamq.cli.TmuxManager", Manager)
@ -88,7 +89,8 @@ def test_interactive_no_service_start_attaches(monkeypatch, capsys):
def preflight(self, repos, command):
return "plan"
def ensure_plan(self, plan):
def ensure_plan(self, plan, *, tap=True):
assert tap is False
return endpoint
attached = []
@ -118,7 +120,8 @@ def test_registration_failure_rolls_back_created_windows(monkeypatch, capsys):
def preflight(self, repos, command):
return "plan"
def ensure_plan(self, plan):
def ensure_plan(self, plan, *, tap=True):
assert tap is True
return endpoint
def rollback(self, value):

View file

@ -161,6 +161,63 @@ def test_isolated_installed_tool_session_smoke(tmp_path):
assert status["service"] is True
assert [item["endpoint_id"] for item in status["endpoints"]] == [started["instance_id"]]
assert run(str(tamq), "stop").returncode == 0
assert json.loads(run(str(tamq), "status").stdout)["service"] is False
restarted = json.loads(
run(
str(tamq),
"start",
"--detach",
"--command",
"alpha-agent",
"railiance-platform",
"activity-core",
).stdout
)
assert restarted["instance_id"] == started["instance_id"]
assert run(
*tmux,
"list-windows",
"-t",
"tamq",
"-F",
"#{window_name}|#{pane_current_path}|#{pane_pid}",
).stdout.splitlines() == rows
run(*tmux, "kill-session", "-t", "tamq")
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
status = json.loads(run(str(tamq), "status").stdout)
if status["endpoints"] == []:
break
time.sleep(0.05)
assert status["endpoints"] == []
recovered = json.loads(
run(
str(tamq),
"start",
"--detach",
"--command",
"alpha-agent",
"railiance-platform",
"activity-core",
).stdout
)
assert recovered["instance_id"] != started["instance_id"]
recovered_rows = run(
*tmux,
"list-windows",
"-t",
"tamq",
"-F",
"#{window_name}|#{pane_current_path}",
).stdout.splitlines()
assert recovered_rows == [
f"railiance-platform|{repo_a}",
f"activity-core|{repo_b}",
]
assert run(str(tamq), "stop").returncode == 0
finally:
subprocess.run(
[str(tamq), "stop"],

56
tests/test_ptytap.py Normal file
View file

@ -0,0 +1,56 @@
import fcntl
import os
import pty
import struct
import termios
from tamq.ptytap import PtyTap, copy_winsize, write_all
class RecordingBroker:
def __init__(self):
self.lines = []
def inspect_line(self, line):
self.lines.append(line)
return line.startswith("@")
def test_input_observer_accepts_raw_terminal_carriage_returns():
broker = RecordingBroker()
observed = []
tap = PtyTap(["true"], broker, on_line=observed.append)
buffer = bytearray()
tap._observe_input(buffer, b"@activity-core: hel")
tap._observe_input(buffer, b"lo\rplain line\n")
assert broker.lines == ["@activity-core: hello", "plain line"]
assert observed == ["@activity-core: hello"]
def test_copy_winsize_preserves_rows_columns_and_pixels():
source_master, source_slave = pty.openpty()
target_master, target_slave = pty.openpty()
expected = struct.pack("HHHH", 41, 103, 900, 1600)
try:
fcntl.ioctl(source_slave, termios.TIOCSWINSZ, expected)
assert copy_winsize(source_slave, target_master) is True
actual = fcntl.ioctl(target_slave, termios.TIOCGWINSZ, b"\0" * 8)
assert actual == expected
finally:
for fd in (source_master, source_slave, target_master, target_slave):
os.close(fd)
def test_write_all_retries_partial_writes(monkeypatch):
writes = []
def partial_write(fd, data):
chunk = bytes(data[:2])
writes.append((fd, chunk))
return len(chunk)
monkeypatch.setattr(os, "write", partial_write)
write_all(9, b"abcde")
assert writes == [(9, b"ab"), (9, b"cd"), (9, b"e")]

View file

@ -0,0 +1,99 @@
import os
import shutil
import sys
import time
from pathlib import Path
from uuid import uuid4
import pytest
from tamq.tmux import LaunchPlan, TmuxManager
@pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is not installed")
def test_tap_propagates_terminal_size_resize_and_raw_mouse_input(tmp_path, monkeypatch):
repo = tmp_path / "activity-core"
repo.mkdir()
fixture = tmp_path / "terminal_fixture.py"
fixture.write_text(
"import os, signal, tty\n"
"def size(prefix):\n"
" value = os.get_terminal_size(0)\n"
" print(f'{prefix} {value.lines}x{value.columns}', flush=True)\n"
"def resized(signum, frame): size('RESIZE')\n"
"signal.signal(signal.SIGWINCH, resized)\n"
"size('INITIAL')\n"
"tty.setraw(0)\n"
"print('READY', flush=True)\n"
"data = b''\n"
"while not data.endswith(b'M'):\n"
" data += os.read(0, 1024)\n"
"print(f'INPUT {data.hex()}', flush=True)\n"
"while True: signal.pause()\n",
encoding="utf-8",
)
project_src = str(Path(__file__).resolve().parents[1] / "src")
existing_pythonpath = os.environ.get("PYTHONPATH")
monkeypatch.setenv(
"PYTHONPATH",
project_src if not existing_pythonpath else f"{project_src}:{existing_pythonpath}",
)
monkeypatch.setenv("TAMQ_STATE_DIR", str(tmp_path / "state"))
socket_name = f"tamq-pty-{os.getpid()}-{uuid4().hex[:8]}"
session = f"tamq-pty-{uuid4().hex[:8]}"
manager = TmuxManager(
session,
tmux_command=("tmux", "-L", socket_name),
tamq_command=(sys.executable, "-m", "tamq.cli"),
)
plan = LaunchPlan(
("activity-core",),
{"activity-core": str(repo)},
(sys.executable, str(fixture)),
)
def capture() -> str:
return manager._run("capture-pane", "-p", "-t", f"{session}:activity-core")
def wait_for(marker: str) -> str:
deadline = time.monotonic() + 5
output = ""
while time.monotonic() < deadline:
output = capture()
if marker in output:
return output
time.sleep(0.05)
pytest.fail(f"timed out waiting for {marker!r}; pane contained:\n{output}")
try:
manager.ensure_plan(plan)
output = wait_for("READY")
dimensions = manager._run(
"display-message",
"-p",
"-t",
f"{session}:activity-core",
"#{pane_height}x#{pane_width}",
)
assert f"INITIAL {dimensions}" in output
mouse_sequence = "\x1b[<64;10;5M"
manager._run(
"send-keys", "-t", f"{session}:activity-core", "-l", "--", mouse_sequence
)
output = wait_for(f"INPUT {mouse_sequence.encode().hex()}")
assert f"INPUT {mouse_sequence.encode().hex()}" in output
manager._run("resize-window", "-t", session, "-x", "100", "-y", "40")
resized = manager._run(
"display-message",
"-p",
"-t",
f"{session}:activity-core",
"#{pane_height}x#{pane_width}",
)
output = wait_for(f"RESIZE {resized}")
assert f"RESIZE {resized}" in output
finally:
manager._run("kill-server", check=False)

View file

@ -13,6 +13,9 @@ class FakeControl:
def start(self):
return None
def session_exists(self, expected_pid=None):
return True
def inject(self, window, text):
self.injected.append((window, text))
@ -29,3 +32,17 @@ def test_service_delivery_injects_pending_messages(tmp_path, monkeypatch):
assert FakeControl.instances[-1].injected == [("tamq:repo-b", "#repo-a: continue")]
assert store.list()[0]["message_id"] == message_id
assert store.list()[0]["state"] == "injected"
def test_service_disconnects_disappeared_tmux_endpoint(tmp_path, monkeypatch):
store = Store(tmp_path / "queue.sqlite3")
store.register_endpoint("tmux-amq-9-boot", 9, "tamq", ["repo-b"])
class MissingControl(FakeControl):
def session_exists(self, expected_pid=None):
assert expected_pid == 9
return False
monkeypatch.setattr("tamq.service.ControlModeClient", MissingControl)
Service(store=store)._deliver_once()
assert store.endpoints() == []

View file

@ -93,6 +93,31 @@ def test_preflight_rejects_foreign_session(tmp_path, monkeypatch):
manager.preflight(["a"])
def test_tmux_only_plan_starts_agent_without_tap(tmp_path, monkeypatch):
manager = tmux.TmuxManager("tamq-test")
calls = []
plan = tmux.LaunchPlan(("a",), {"a": str(tmp_path)}, ("codex", "--quiet"))
def run(*args, check=True):
calls.append(args)
if args[:2] == ("display-message", "-p"):
return "42"
if args[:2] == ("list-windows", "-t"):
return "__tamq_boot"
return ""
monkeypatch.setattr(manager, "_run", run)
monkeypatch.setattr(
tmux.subprocess,
"run",
lambda *args, **kwargs: type("R", (), {"returncode": 1})(),
)
manager.ensure_plan(plan, tap=False)
send = next(call for call in calls if call and call[0] == "send-keys")
assert "codex --quiet" in send
assert "tamq tap" not in send
def test_preflight_rejects_missing_agent(tmp_path, monkeypatch):
repo = tmp_path / "a"
repo.mkdir()