diff --git a/tests/test_wrapper_unix_inject.py b/tests/test_wrapper_unix_inject.py new file mode 100644 index 00000000..d0d0d436 --- /dev/null +++ b/tests/test_wrapper_unix_inject.py @@ -0,0 +1,50 @@ +"""Tests for wrapper_unix.inject tri-state result contract. + +inject() must report "deferred" only when tmux rejected the text command +(retry is safe), and "injected-uncertain" once text may have reached the +composer — a retry there could duplicate the prompt. +""" + +import sys +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import wrapper_unix # noqa: E402 + + +class UnixInjectContractTests(unittest.TestCase): + @mock.patch.object(wrapper_unix.time, "sleep") + @mock.patch.object(wrapper_unix.subprocess, "run") + def test_returns_explicit_success_only_after_text_and_enter(self, run, _sleep): + run.side_effect = [ + mock.Mock(returncode=0), mock.Mock(returncode=0), + mock.Mock(returncode=0), mock.Mock(returncode=1), + ] + self.assertEqual( + wrapper_unix.inject("task", tmux_session="s"), "injected" + ) + self.assertEqual( + wrapper_unix.inject("task", tmux_session="s"), + "injected-uncertain", + ) + + @mock.patch.object(wrapper_unix.subprocess, "run") + def test_text_rejection_is_retryable_and_timeout_is_uncertain(self, run): + run.return_value = mock.Mock(returncode=1) + self.assertEqual( + wrapper_unix.inject("task", tmux_session="s"), "deferred" + ) + run.side_effect = wrapper_unix.subprocess.TimeoutExpired("tmux", 1) + self.assertEqual( + wrapper_unix.inject("task", tmux_session="s"), + "injected-uncertain", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/wrapper_unix.py b/wrapper_unix.py index d37f315d..8d5c119b 100644 --- a/wrapper_unix.py +++ b/wrapper_unix.py @@ -39,20 +39,40 @@ def _check_tmux(): sys.exit(1) -def inject(text: str, *, tmux_session: str, delay: float = 0.3): - """Send text + Enter to a tmux session via send-keys.""" +def inject(text: str, *, tmux_session: str, delay: float = 0.3, + command_timeout: float = 5.0): + """Send text + Enter to a tmux session via send-keys. + + Returns "injected" when both commands succeed. Returns "deferred" when + tmux positively rejects the text command — nothing reached the composer, + so the caller can safely retry. Once the text may have reached the + composer, any timeout or Enter failure returns "injected-uncertain": + retyping at that point could append to (or even submit) a duplicate + prompt, so the caller must not retype automatically. + """ # Use -l to send text literally (avoids misinterpreting as key names), # then send Enter as a separate key press - subprocess.run( - ["tmux", "send-keys", "-t", tmux_session, "-l", text], - capture_output=True, - ) + try: + text_result = subprocess.run( + ["tmux", "send-keys", "-t", tmux_session, "-l", text], + capture_output=True, timeout=command_timeout, + ) + except subprocess.TimeoutExpired: + return "injected-uncertain" + if text_result.returncode != 0: + return "deferred" # Scale delay with text length so longer prompts get more processing time time.sleep(max(delay, len(text) * 0.001)) - subprocess.run( - ["tmux", "send-keys", "-t", tmux_session, "Enter"], - capture_output=True, - ) + try: + enter_result = subprocess.run( + ["tmux", "send-keys", "-t", tmux_session, "Enter"], + capture_output=True, timeout=command_timeout, + ) + except subprocess.TimeoutExpired: + return "injected-uncertain" + if enter_result.returncode != 0: + return "injected-uncertain" + return "injected" def get_activity_checker(session_name, trigger_flag=None):