feat: add conversational replies and stable output
Some checks failed
tamq-ci / test (push) Failing after 6s
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:
parent
704eeea5c1
commit
a4ba6e32e5
17 changed files with 363 additions and 59 deletions
|
|
@ -18,12 +18,12 @@ def test_pane_tty_is_resolved_through_tmux(monkeypatch):
|
|||
|
||||
def run(command, **kwargs):
|
||||
calls.append(command)
|
||||
return type("Result", (), {"returncode": 0, "stdout": "/dev/pts/7\n", "stderr": ""})()
|
||||
return type("Result", (), {"returncode": 0, "stdout": "/dev/pts/7|12|8|24|0\n", "stderr": ""})()
|
||||
|
||||
monkeypatch.setattr(control.subprocess, "run", run)
|
||||
client = ControlModeClient("tamq", tmux_command=("tmux", "-L", "test"))
|
||||
assert client.pane_tty("tamq:audit-core") == "/dev/pts/7"
|
||||
assert calls == [[
|
||||
"tmux", "-L", "test", "display-message", "-p", "-t",
|
||||
"tamq:audit-core", "#{pane_tty}",
|
||||
"tamq:audit-core", "#{pane_tty}|#{cursor_x}|#{cursor_y}|#{pane_height}|#{alternate_on}",
|
||||
]]
|
||||
|
|
|
|||
|
|
@ -32,6 +32,43 @@ def test_manual_send_inbox_and_ack_use_window_repository_identity(tmp_path, monk
|
|||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
def test_bare_reply_targets_latest_inbound_sender(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.setenv("TAMQ_STATE_DIR", str(tmp_path / "state"))
|
||||
monkeypatch.setenv("TAMQ_REPO", "audit-core")
|
||||
monkeypatch.setattr("tamq.cli.ping", service_is_down)
|
||||
monkeypatch.setattr("tamq.cli.validate_targets", lambda repos: None)
|
||||
store = Store(tmp_path / "state" / "tamq.sqlite3")
|
||||
store.add("flex-auth", "audit-core", "older")
|
||||
latest = store.add("railiance-platform", "audit-core", "latest")
|
||||
store.acknowledge(latest)
|
||||
store.add("audit-core", "audit-core", "self note")
|
||||
store.close()
|
||||
|
||||
assert main(["reply", "--", "--looks-like-an-option", "thanks"]) == 0
|
||||
message_id = capsys.readouterr().out.strip()
|
||||
|
||||
store = Store(tmp_path / "state" / "tamq.sqlite3")
|
||||
row = next(row for row in store.list() if row["message_id"] == message_id)
|
||||
assert row["sender_repo"] == "audit-core"
|
||||
assert row["target_repo"] == "railiance-platform"
|
||||
assert row["body"] == "--looks-like-an-option thanks"
|
||||
store.close()
|
||||
|
||||
|
||||
def test_bare_reply_requires_managed_identity_and_prior_sender(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.setenv("TAMQ_STATE_DIR", str(tmp_path / "state"))
|
||||
monkeypatch.setattr("tamq.cli.ping", service_is_down)
|
||||
monkeypatch.setattr("tamq.cli.validate_targets", lambda repos: None)
|
||||
monkeypatch.delenv("TAMQ_REPO", raising=False)
|
||||
|
||||
assert main(["reply", "hello"]) == 2
|
||||
assert "managed window" in capsys.readouterr().err
|
||||
|
||||
monkeypatch.setenv("TAMQ_REPO", "audit-core")
|
||||
assert main(["reply", "hello"]) == 2
|
||||
assert "no counterparty" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_inbox_requires_repository_outside_managed_window(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.setenv("TAMQ_STATE_DIR", str(tmp_path / "state"))
|
||||
monkeypatch.delenv("TAMQ_REPO", raising=False)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from tamq.control import PaneDisplay
|
||||
from tamq.service import Service
|
||||
from tamq.store import Store
|
||||
|
||||
|
|
@ -19,8 +20,14 @@ class FakeControl:
|
|||
def inject(self, window, text):
|
||||
self.injected.append((window, text))
|
||||
|
||||
def pane_tty(self, window):
|
||||
return f"/dev/pts/{window.rsplit(':', 1)[-1]}"
|
||||
def pane_display(self, window):
|
||||
return PaneDisplay(
|
||||
tty_path=f"/dev/pts/{window.rsplit(':', 1)[-1]}",
|
||||
cursor_x=0,
|
||||
cursor_y=0,
|
||||
pane_height=24,
|
||||
alternate_on=False,
|
||||
)
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -59,3 +59,15 @@ def test_mark_displayed_releases_lease_without_acknowledging(tmp_path):
|
|||
assert row["displayed_at"] is not None
|
||||
assert store.db.execute("SELECT COUNT(*) FROM leases").fetchone()[0] == 0
|
||||
store.close()
|
||||
|
||||
|
||||
def test_latest_counterparty_uses_latest_inbound_message_regardless_of_state(tmp_path):
|
||||
store = Store(tmp_path / "queue.sqlite3")
|
||||
store.add("audit-core", "audit-core", "self note")
|
||||
older = store.add("flex-auth", "audit-core", "first")
|
||||
store.acknowledge(older)
|
||||
store.add("railiance-platform", "audit-core", "latest")
|
||||
|
||||
assert store.latest_counterparty("audit-core") == "railiance-platform"
|
||||
assert store.latest_counterparty("unknown") is None
|
||||
store.close()
|
||||
|
|
|
|||
|
|
@ -16,6 +16,24 @@ def test_comment_format_escapes_controls_and_prefixes_every_line():
|
|||
assert format_comment("repo-a", "first\nsecond\x1b[31m", "m-1") == (
|
||||
"#repo-a: first\n# second\\x1b[31m [m-1]"
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_frame_scrolls_only_rows_above_the_cursor():
|
||||
assert terminal_frame(
|
||||
"repo-a", "first\nsecond", "m-1", cursor_y=8, pane_height=24
|
||||
) == (
|
||||
"\x1b7\x1b[1;8r\x1b[8;1H"
|
||||
"\n\r#repo-a: first\n\r# second [m-1]"
|
||||
"\x1b[r\x1b8"
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_frame_falls_back_when_stable_region_is_not_safe():
|
||||
expected = "\r\n#repo-a: hello [m-1]\r\n"
|
||||
assert terminal_frame("repo-a", "hello", "m-1", cursor_y=0, pane_height=24) == expected
|
||||
assert terminal_frame(
|
||||
"repo-a", "hello", "m-1", cursor_y=8, pane_height=24, alternate_on=True
|
||||
) == expected
|
||||
assert terminal_frame("repo-a", "hello", "m-1") == (
|
||||
"\r\n#repo-a: hello [m-1]\r\n"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import pytest
|
|||
from tamq.broker import BrokerIdentity, InputBroker
|
||||
from tamq.control import ControlModeClient
|
||||
from tamq.store import Store
|
||||
from tamq.terminal import terminal_frame, write_terminal_output
|
||||
from tamq.tmux import LaunchPlan, TmuxManager
|
||||
|
||||
|
||||
|
|
@ -112,3 +113,88 @@ def test_real_tmux_starts_two_repo_windows_and_reuses_them(tmp_path, monkeypatch
|
|||
store.close()
|
||||
finally:
|
||||
manager._run("kill-server", check=False)
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is not installed")
|
||||
def test_real_tmux_output_preserves_partial_input_line_and_cursor(tmp_path):
|
||||
repo = tmp_path / "audit-core"
|
||||
repo.mkdir()
|
||||
socket_name = f"tamq-output-{os.getpid()}-{uuid4().hex[:8]}"
|
||||
session = f"tamq-output-{uuid4().hex[:8]}"
|
||||
manager = TmuxManager(
|
||||
session,
|
||||
tmux_command=("tmux", "-L", socket_name),
|
||||
tamq_command=(sys.executable, "-m", "tamq.cli"),
|
||||
command_dir=tmp_path / "commands",
|
||||
)
|
||||
target = f"{session}:audit-core"
|
||||
plan = LaunchPlan(("audit-core",), {"audit-core": str(repo)}, ())
|
||||
|
||||
try:
|
||||
manager.ensure_plan(plan)
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
current_command = manager._run(
|
||||
"display-message", "-p", "-t", target, "#{pane_current_command}"
|
||||
)
|
||||
if current_command in {"bash", "sh", "zsh", "fish"}:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert current_command in {"bash", "sh", "zsh", "fish"}
|
||||
manager._run(
|
||||
"send-keys", "-t", target, "-l", "--", "printf 'line-one\\nline-two\\n'"
|
||||
)
|
||||
manager._run("send-keys", "-t", target, "Enter")
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
initial_capture = manager._run("capture-pane", "-p", "-t", target)
|
||||
initial_lines = initial_capture.splitlines()
|
||||
if "line-one" in initial_lines and "line-two" in initial_lines:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert "line-one" in initial_lines and "line-two" in initial_lines
|
||||
manager._run("send-keys", "-t", target, "-l", "--", "PARTIAL-INPUT")
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
before_capture = manager._run("capture-pane", "-p", "-t", target)
|
||||
if "PARTIAL-INPUT" in before_capture:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert "PARTIAL-INPUT" in before_capture
|
||||
|
||||
control = ControlModeClient(
|
||||
session,
|
||||
tmux_command=("tmux", "-L", socket_name),
|
||||
)
|
||||
before = control.pane_display(target)
|
||||
write_terminal_output(
|
||||
before.tty_path,
|
||||
terminal_frame(
|
||||
"flex-auth",
|
||||
"stable-message",
|
||||
"m-stable",
|
||||
cursor_y=before.cursor_y,
|
||||
pane_height=before.pane_height,
|
||||
alternate_on=before.alternate_on,
|
||||
),
|
||||
)
|
||||
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
after_capture = manager._run("capture-pane", "-p", "-t", target)
|
||||
if "#flex-auth: stable-message [m-stable]" in after_capture:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
after = control.pane_display(target)
|
||||
assert "#flex-auth: stable-message [m-stable]" in after_capture
|
||||
before_input = next(line for line in before_capture.splitlines() if "PARTIAL-INPUT" in line)
|
||||
after_lines = after_capture.splitlines()
|
||||
assert any("PARTIAL-INPUT" in line for line in after_lines), repr(after_capture)
|
||||
after_input_index = next(
|
||||
index for index, line in enumerate(after_lines) if "PARTIAL-INPUT" in line
|
||||
)
|
||||
assert after_lines[after_input_index] == before_input
|
||||
assert after_lines[after_input_index - 1] == "#flex-auth: stable-message [m-stable]"
|
||||
assert (after.cursor_x, after.cursor_y) == (before.cursor_x, before.cursor_y)
|
||||
finally:
|
||||
manager._run("kill-server", check=False)
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ def test_neutral_plan_starts_shell_without_sending_keystrokes(tmp_path, monkeypa
|
|||
assert (command_dir / "@a:").is_file()
|
||||
assert (command_dir / "@b").is_file()
|
||||
assert (command_dir / "@b:").is_file()
|
||||
assert (command_dir / "@").is_file()
|
||||
|
||||
|
||||
def test_address_commands_preserve_message_arguments(tmp_path):
|
||||
|
|
@ -173,6 +174,14 @@ def test_address_commands_preserve_message_arguments(tmp_path):
|
|||
)
|
||||
assert result.stdout == "send -- @audit-core: Some message! $value ; literal --from\n"
|
||||
|
||||
result = subprocess.run(
|
||||
[str(command_dir / "@"), "Some reply!", "$value", "--literal"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
assert result.stdout == "reply -- Some reply! $value --literal\n"
|
||||
|
||||
|
||||
def test_address_commands_reject_unsafe_repository_names(tmp_path):
|
||||
manager = tmux.TmuxManager("tamq-test", command_dir=tmp_path / "commands")
|
||||
|
|
@ -192,6 +201,18 @@ def test_address_commands_do_not_replace_unowned_commands(tmp_path):
|
|||
assert existing.read_text(encoding="utf-8") == "#!/bin/sh\necho mine\n"
|
||||
|
||||
|
||||
def test_address_commands_do_not_replace_unowned_bare_reply(tmp_path):
|
||||
command_dir = tmp_path / "commands"
|
||||
command_dir.mkdir()
|
||||
existing = command_dir / "@"
|
||||
existing.write_text("#!/bin/sh\necho mine\n", encoding="utf-8")
|
||||
manager = tmux.TmuxManager("tamq-test", command_dir=command_dir)
|
||||
|
||||
with pytest.raises(tmux.TmuxError, match="refusing to replace"):
|
||||
manager._install_address_commands(["audit-core"])
|
||||
assert existing.read_text(encoding="utf-8") == "#!/bin/sh\necho mine\n"
|
||||
|
||||
|
||||
def test_preflight_rejects_missing_agent(tmp_path, monkeypatch):
|
||||
repo = tmp_path / "a"
|
||||
repo.mkdir()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue