Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions tests/test_wrapper_unix_inject.py
Original file line number Diff line number Diff line change
@@ -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()
40 changes: 30 additions & 10 deletions wrapper_unix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down