Files
tai/tai_test.py
T
retoorandClaude Sonnet 5 9dbd1fe667 Add plexer subsystem, container test infra, and last-resort opencode box backend
- Plexer: always-active terminal multiplexing. NativePlexerController/
  TmuxPlexerController share one interface, chosen by which env var is
  already set (TAI_PLEXER_SOCKET vs TMUX_PANE); NativePlexerBackend is
  the pty.fork()-based wrapper (raw mode, SIGWINCH resize forwarding,
  a Unix-socket control protocol, continuous session logging, a
  Ctrl+A prefix key distinct from tmux's own Ctrl+B). Auto-wraps new
  interactive shells via a .bashrc hook mirroring the existing
  command-not-found install pattern, gated on $TMUX/$TAI_PLEXER_ACTIVE/
  a $HOME/.tai-no-plexer bypass. New plexer_send/read/type/status
  tools, env-gated on a detected controller.
- tests/container.py + docker-compose.yml + tests/docker/Dockerfile:
  a disposable podman/docker image for the tests that must never touch
  a real machine (installing the plexer hook into a real ~/.bashrc, a
  live tmux session). Required suite (test_seal.py, test_tai.py,
  tests/plexer.py, tests/bashrc.py) gates the result; tests/rag.py
  runs as best-effort since its real-LLM dependency can't be satisfied
  inside a disposable container with no nested engine.
- tests/bashrc.py: real end-to-end hook install + bash -i auto-wrap,
  anti-recursion, tmux-active skip, non-interactive skip, a live
  TmuxPlexerController test, and an install/uninstall round-trip.
- tests/plexer.py: NativePlexerBackend under a real pty, plus a
  dedicated robustness pass (file permissions, >64KB payload framing,
  a malformed control request, a genuine self-deadlock fix in the
  write-to-child path).
- tai_test.py: the shared async process-driving framework (pipe/pty
  transport, group-wide kill-and-reap, a StdinBridge for --interactive)
  both test files build on.
- ChatClient last-resort backend: when there's no local opencode CLI
  but the sandbox box is already running, install opencode into it
  (official Node tarball, not apt -- apt's nodejs/npm pull in
  webpack/babel/jest as transitive deps) and route through it instead
  of giving up. Only reachable after the first retry round already
  failed everything else; never creates the box itself. TmuxPlexerController.status()
  fixed to use `list-panes` instead of `display-message -p`, which
  silently fell back to the default pane instead of erroring against
  a dead target.
- Fix 4 pre-existing test_tai.py bugs surfaced by running it in a
  clean environment for the first time: two stale mock signatures
  (subprocess.run's stdout= kwarg, Chat.complete's deadline= kwarg),
  one $TMUX-dependent catalog assertion, one tty-dependent color
  assertion.
- research/: a sourced research archive (Source Dossier/Citation
  Card/Conclusion tiers) backing the above design decisions, with its
  own audit.py consistency checker.
- .gitignore: fix a blanket *.md rule that was about to silently drop
  the entire research/ archive from version control; add __pycache__/
  and the one-off debug transcript this session started from.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-10 04:36:05 +02:00

528 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""tai_test.py — the integration-test framework for driving tai.py as a real subprocess.
This is a library, not a test itself (it has no "test_" prefix on purpose — nothing
here is collected by unittest/pytest). Integration tests that use it live under
tests/ and import it; see tests/README.md for the conventions and tests/rag.py for
a worked example.
Design, and why, in one paragraph per decision (full research notes are archived
under research/ — see research/README.md for how this project keeps that archive):
- Two transport modes, one interface. "pipe" mode uses asyncio's native
create_subprocess_exec with PIPE streams — the simplest, most deterministic
choice, and what one-shot `tai.py <prompt>` invocations should use, since they
just run once and exit. "pty" mode opens a real pseudo-terminal (the stdlib
`pty` module) and gives the child its slave end as stdin/stdout/stderr, because
tai.py's own code branches on `sys.stdin.isatty()` (approval prompts, readline)
— a plain pipe makes it behave as if piped, not as if a human were typing. Both
modes end up exposing the same asyncio.StreamReader-based interface (the pty
master fd is wrapped into one via loop.connect_read_pipe), so callers never
need to know which transport a given test is using.
- start_new_session=True, always, plus a kill-the-whole-group escalation
(SIGTERM, wait, then SIGKILL, then always proc.wait() to reap). A plain
proc.terminate() only signals the direct child; if tai.py ever forks a
subagent or a shell command, a timeout needs to take the whole tree down, not
just the leader, and failing to reap after killing leaves a zombie.
- Non-blocking everything, no polling. The child's output is consumed through
asyncio.StreamReader (native backpressure, no busy loop). A human's live
stdin, when a test opts into --interactive, is bridged into the loop with
loop.add_reader + raw os.read on the fd (never the buffered sys.stdin, which
blocks) rather than a background thread — matching how the child's own output
is handled, and confirmed against multiple independent sources as the correct
pattern. Full sourcing for every design decision in this module — pty vs.
pipes, the connect_read_pipe trick, the killpg escalation, the TestCase
shape itself — is archived at research/async-pty-process-testing/ per this
project's standing research rule (see AGENTS.md).
- A known, accepted limitation: the pty mode does not explicitly call
ioctl(TIOCSCTTY), so the child's session may not become the pty's controlling
terminal in the job-control sense. isatty() on the fd is unaffected by this
(it's a property of the fd, not of session leadership) so approval-prompt and
readline behavior work correctly; what would NOT work is SIGWINCH-based resize
forwarding or Ctrl-C-as-SIGINT generated by the tty line discipline. Tests
that only script line-based input/output (the normal case) are unaffected.
"""
import argparse
import asyncio
import dataclasses
import datetime
import os
import pty
import re
import signal
import sys
import time
import traceback
from pathlib import Path
DEFAULT_TAI_PATH = Path(__file__).resolve().parent / "tai.py"
ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
REPL_PROMPT_RE = re.compile(r"tai\[[^\]]*\]\xe2\x80\xba|tai\[[^\]]*\]›")
def strip_ansi(text):
return ANSI_RE.sub("", text)
# --------------------------------------------------------------------------- #
# Exceptions. Every failure a test can hit is one of these, and every one of
# them carries the transcript collected so far, so a failing test is debuggable
# from its log file alone, without reproducing it.
# --------------------------------------------------------------------------- #
class TaiTestError(Exception):
def __init__(self, message, transcript=None):
super().__init__(message)
self.transcript = list(transcript or [])
class TestTimeout(TaiTestError):
"""Raised when a step, or the test as a whole, exceeds its timeout."""
class TestCrashed(TaiTestError):
"""Raised when the child process exited (or died to a signal) unexpectedly."""
def __init__(self, message, returncode, transcript=None):
super().__init__(message, transcript)
self.returncode = returncode
class TestAssertionFailed(TaiTestError):
"""Raised by a test's verify() when the captured behavior doesn't match expectations."""
# --------------------------------------------------------------------------- #
# Transcript + result data types.
# --------------------------------------------------------------------------- #
@dataclasses.dataclass
class OutputLine:
stream: str # "stdout" | "stdin" (stderr is merged into stdout in pipe mode; pty merges all three)
text: str
ts: float
@dataclasses.dataclass
class TestResult:
name: str
passed: bool
duration: float
error: str = None
transcript: list = dataclasses.field(default_factory=list)
log_path: str = None
# --------------------------------------------------------------------------- #
# TeeLogger: every byte a test sees also goes to screen and to a log file,
# synchronously (plain stdlib file I/O, flushed per write) — no extra
# dependency, and fast enough that it never becomes the bottleneck next to a
# network-bound LLM call or a human typing.
# --------------------------------------------------------------------------- #
class TeeLogger:
def __init__(self, log_path, echo=True):
self.log_path = Path(log_path)
self.log_path.parent.mkdir(parents=True, exist_ok=True)
self._fh = open(self.log_path, "a", encoding="utf-8")
self.echo = echo
def write(self, text):
self._fh.write(text)
self._fh.flush()
if self.echo:
sys.stdout.write(text)
sys.stdout.flush()
def close(self):
try:
self._fh.close()
except OSError:
pass
# --------------------------------------------------------------------------- #
# StdinBridge: bridges the REAL terminal's stdin into an asyncio.Queue of
# complete lines, without blocking the event loop and without a thread.
# No-ops safely (returns False from start()) if real stdin isn't a tty, e.g.
# under CI — a test must not hang waiting for input nobody can type.
# --------------------------------------------------------------------------- #
class StdinBridge:
def __init__(self):
self.queue = asyncio.Queue()
self._fd = None
self._buf = b""
self._active = False
self._loop = None
def start(self):
if not sys.stdin.isatty():
return False
self._loop = asyncio.get_running_loop()
self._fd = sys.stdin.fileno()
self._loop.add_reader(self._fd, self._on_readable)
self._active = True
return True
def _on_readable(self):
try:
chunk = os.read(self._fd, 4096)
except OSError:
chunk = b""
if not chunk:
self.stop()
self.queue.put_nowait(None)
return
self._buf += chunk
while b"\n" in self._buf:
line, self._buf = self._buf.split(b"\n", 1)
self.queue.put_nowait(line.decode(errors="replace"))
def stop(self):
if self._active:
try:
self._loop.remove_reader(self._fd)
except (ValueError, OSError):
pass
self._active = False
# --------------------------------------------------------------------------- #
# ManagedProcess: spawns and safely controls one subprocess, in "pipe" or
# "pty" mode, with a single interface either way.
# --------------------------------------------------------------------------- #
class ManagedProcess:
def __init__(self, command, mode="pipe", cwd=None, env=None, on_line=None):
self.command = list(command)
self.mode = mode
self.cwd = cwd
self.env = env
self._on_line = on_line
self.proc = None
self._master_fd = None
self._master_file = None
self._reader = None
self._lines = []
async def start(self):
loop = asyncio.get_running_loop()
if self.mode == "pty":
master_fd, slave_fd = pty.openpty()
self.proc = await asyncio.create_subprocess_exec(
*self.command,
stdin=slave_fd, stdout=slave_fd, stderr=slave_fd,
cwd=self.cwd, env=self.env,
start_new_session=True,
)
os.close(slave_fd)
self._master_fd = master_fd
self._master_file = os.fdopen(master_fd, "rb", buffering=0)
reader = asyncio.StreamReader(loop=loop)
protocol = asyncio.StreamReaderProtocol(reader, loop=loop)
await loop.connect_read_pipe(lambda: protocol, self._master_file)
self._reader = reader
else:
self.proc = await asyncio.create_subprocess_exec(
*self.command,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=self.cwd, env=self.env,
start_new_session=True,
)
self._reader = self.proc.stdout
return self
async def send_line(self, text):
data = (text + "\n").encode("utf-8", "replace")
self._record("stdin", text + "\n")
if self.mode == "pty":
os.write(self._master_fd, data)
else:
self.proc.stdin.write(data)
await self.proc.stdin.drain()
def _record(self, stream, text):
line = OutputLine(stream, text, time.time())
self._lines.append(line)
if self._on_line:
self._on_line(line)
async def _read_chunk(self, n=4096):
try:
chunk = await self._reader.read(n)
except (OSError, ValueError, asyncio.IncompleteReadError):
return b""
return chunk
async def wait_for_pattern(self, pattern, timeout=30.0):
"""Accumulates output until `pattern` (compiled regex) matches the
ANSI-stripped buffer; returns everything read before the match."""
buf = ""
deadline = time.monotonic() + timeout
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TestTimeout("timed out waiting for pattern %r" % pattern.pattern, self._lines)
try:
chunk = await asyncio.wait_for(self._read_chunk(), timeout=remaining)
except asyncio.TimeoutError:
raise TestTimeout("timed out waiting for pattern %r" % pattern.pattern, self._lines)
if not chunk:
raise TestCrashed(
"process ended before pattern %r was seen" % pattern.pattern,
self.proc.returncode, self._lines,
)
text = chunk.decode("utf-8", "replace")
self._record("stdout", text)
buf += strip_ansi(text)
match = pattern.search(buf)
if match:
return buf[:match.start()]
async def drain_until_eof(self, timeout=120.0):
"""Reads everything until the process closes its output — the one-shot case."""
deadline = time.monotonic() + timeout
out = []
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TestTimeout("timed out waiting for process output to end", self._lines)
try:
chunk = await asyncio.wait_for(self._read_chunk(), timeout=remaining)
except asyncio.TimeoutError:
raise TestTimeout("timed out waiting for process output to end", self._lines)
if not chunk:
break
text = chunk.decode("utf-8", "replace")
self._record("stdout", text)
out.append(text)
return "".join(out)
async def wait(self, timeout=None):
if timeout is None:
return await self.proc.wait()
try:
return await asyncio.wait_for(self.proc.wait(), timeout)
except asyncio.TimeoutError:
raise TestTimeout("process did not exit within %.1fs" % timeout, self._lines)
async def terminate(self, grace=5.0):
"""Escalating, group-wide, always-reaping shutdown. Safe to call on an
already-exited process, and safe to call twice."""
if self.proc is None or self.proc.returncode is not None:
return self.proc.returncode if self.proc else None
pgid = self.proc.pid
for sig, wait_s in ((signal.SIGTERM, grace), (signal.SIGKILL, None)):
try:
os.killpg(pgid, sig)
except ProcessLookupError:
break
except PermissionError:
break
if wait_s is None:
break
try:
await asyncio.wait_for(self.proc.wait(), wait_s)
break
except asyncio.TimeoutError:
continue
try:
return await asyncio.wait_for(self.proc.wait(), 5.0)
except asyncio.TimeoutError:
return None # best-effort; we tried SIGKILL already
finally:
if self._master_file is not None:
try:
self._master_file.close()
except OSError:
pass
@property
def transcript(self):
return list(self._lines)
# --------------------------------------------------------------------------- #
# TestCase: the thing you extend. setup()/run()/verify()/teardown() are the
# override points; execute() is the orchestration and is not meant to be
# overridden. run() can spawn() zero, one, or many ManagedProcess instances —
# the framework doesn't assume a 1:1 test-to-process relationship, since a
# one-shot-style test naturally spawns many short-lived processes while a
# REPL-style test spawns exactly one long-lived one.
# --------------------------------------------------------------------------- #
class TestCase:
name = None
timeout = 60.0
mode = "pipe" # default transport for spawn() when not overridden per call
extra_args = () # args always passed to tai.py, before whatever spawn() adds
def __init__(self, tai_path=None, interactive=False):
self.name = self.name or type(self).__name__
self.tai_path = Path(tai_path or DEFAULT_TAI_PATH)
self.interactive = interactive
self.logger = None
self._processes = []
self.env_overrides = {}
# ---- override points --------------------------------------------------
async def setup(self):
"""Optional. Prepare fixtures/state (e.g. an isolated TAI_HOME) before any process starts."""
async def run(self):
"""Required. Drive the process(es) via self.spawn(); return whatever verify() needs."""
raise NotImplementedError
async def verify(self, result):
"""Required. Raise TestAssertionFailed (with a clear message) if `result` doesn't hold up."""
raise NotImplementedError
async def teardown(self):
"""Optional. Runs after every spawned process has been terminated, pass or fail."""
# ---- helpers for run() --------------------------------------------------
def build_env(self):
env = dict(os.environ)
env.update(self.env_overrides)
return env
def build_command(self, extra_args=()):
return [sys.executable, str(self.tai_path)] + list(self.extra_args) + list(extra_args)
async def spawn(self, extra_args=(), mode=None, env_overrides=None):
env = self.build_env()
env.update(env_overrides or {})
proc = ManagedProcess(self.build_command(extra_args), mode=mode or self.mode, env=env, on_line=self._on_line)
await proc.start()
self._processes.append(proc)
return proc
def _on_line(self, line):
if self.logger:
self.logger.write(line.text)
def all_transcript(self):
out = []
for proc in self._processes:
out.extend(proc.transcript)
return out
# ---- orchestration (do not override) -----------------------------------
async def execute(self, log_dir):
start = time.monotonic()
stamp = datetime.datetime.now().strftime("%Y%m%dT%H%M%S")
log_path = Path(log_dir) / ("%s-%s.log" % (self.name, stamp))
self.logger = TeeLogger(log_path, echo=True)
self.logger.write("=== %s starting %s ===\n" % (self.name, stamp))
bridge = StdinBridge() if self.interactive else None
bridge_task = None
try:
await self.setup()
if bridge is not None and bridge.start():
bridge_task = asyncio.ensure_future(self._pump_bridge(bridge))
payload = await asyncio.wait_for(self.run(), timeout=self.timeout)
await self.verify(payload)
return TestResult(self.name, True, time.monotonic() - start, transcript=self.all_transcript(), log_path=str(log_path))
except asyncio.TimeoutError:
err = TestTimeout("test exceeded its %.1fs timeout" % self.timeout, self.all_transcript())
return self._failure(err, start, log_path)
except TaiTestError as exc:
return self._failure(exc, start, log_path)
except Exception:
err = TaiTestError(traceback.format_exc(), self.all_transcript())
return self._failure(err, start, log_path)
finally:
if bridge is not None:
bridge.stop()
if bridge_task is not None:
bridge_task.cancel()
for proc in self._processes:
await proc.terminate()
await self.teardown()
self.logger.write("=== %s finished ===\n" % self.name)
self.logger.close()
def _failure(self, exc, start, log_path):
return TestResult(self.name, False, time.monotonic() - start, error=str(exc), transcript=exc.transcript, log_path=str(log_path))
async def _pump_bridge(self, bridge):
"""Forwards live human typing (when --interactive) to whichever process
was spawned most recently — the normal case of exactly one long-lived
REPL-mode process."""
while True:
line = await bridge.queue.get()
if line is None:
break
if self._processes:
await self._processes[-1].send_line(line)
# --------------------------------------------------------------------------- #
# TestRunner + CLI: runs a list of TestCase instances sequentially (they
# share a real terminal and, in --interactive mode, real human attention, so
# parallel execution is deliberately not offered here), prints a summary.
# --------------------------------------------------------------------------- #
class TestRunner:
def __init__(self, cases, log_dir="tests/logs"):
self.cases = cases
self.log_dir = log_dir
async def run_all(self):
results = []
for case in self.cases:
print("\n--- running %s ---" % case.name)
result = await case.execute(self.log_dir)
status = "PASS" if result.passed else "FAIL"
print("--- %s: %s (%.1fs, log: %s) ---" % (case.name, status, result.duration, result.log_path))
if not result.passed:
print(" %s" % (result.error or "").splitlines()[-1] if result.error else " (no error message)")
results.append(result)
self.print_summary(results)
return results
def print_summary(self, results):
print("\n=== summary ===")
for result in results:
status = "PASS" if result.passed else "FAIL"
print("%-6s %-40s %6.1fs %s" % (status, result.name, result.duration, result.log_path))
passed = sum(1 for r in results if r.passed)
print("%d/%d passed" % (passed, len(results)))
def main_cli(cases, default_log_dir=None):
"""Standard entrypoint for a tests/*.py module: sys.exit(main_cli(ALL_CASES)).
default_log_dir should normally be the calling module's own directory,
e.g. str(Path(__file__).resolve().parent / "logs") — passed explicitly
because __file__ inside this function would resolve to tai_test.py's own
location, not the caller's."""
parser = argparse.ArgumentParser(description="run tai integration tests")
parser.add_argument("--interactive", action="store_true", help="let you type into the running process live, alongside the scripted input")
parser.add_argument("--filter", default=None, help="only run tests whose name contains this substring")
parser.add_argument("--timeout", type=float, default=None, help="override every selected test's timeout, in seconds")
parser.add_argument("--log-dir", default=default_log_dir or str(Path(__file__).resolve().parent / "logs"))
args = parser.parse_args()
selected = [case for case in cases if not args.filter or args.filter in case.name]
if not selected:
print("no tests matched filter %r" % args.filter)
return 1
for case in selected:
case.interactive = args.interactive
if args.timeout:
case.timeout = args.timeout
runner = TestRunner(selected, log_dir=args.log_dir)
results = asyncio.run(runner.run_all())
return 0 if all(r.passed for r in results) else 1
if __name__ == "__main__":
print(__doc__)
sys.exit(0)