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>
This commit is contained in:
2026-10-10 04:36:05 +02:00
co-authored by Claude Sonnet 5
parent f078ab4731
commit 9dbd1fe667
247 changed files with 11187 additions and 26 deletions
View File
+389
View File
@@ -0,0 +1,389 @@
#!/usr/bin/env python3
"""tests/bashrc.py — the tests that can only run honestly inside the throwaway
container (see tests/docker/Dockerfile, docker-compose.yml, tests/container.py).
Everything here either installs a real hook into a real $HOME/.bashrc and
spawns a real `bash -i` to watch it fire, or drives a real `tmux` session.
Both are things no test should ever do against the machine actually running
Claude Code -- so every test case that touches $HOME/.bashrc checks
TAI_CONTAINER_TEST=1 in setup() and refuses outright if it's missing, rather
than trusting whoever runs this file to remember to only run it in the
container. tests/docker/Dockerfile sets that var; nothing else should.
Deterministic throughout, same as tests/plexer.py: no LLM involved, this is
pty/shell/signal mechanics, not a model decision.
"""
import asyncio
import glob
import os
import re
import shutil
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from tai_test import TestCase, TestAssertionFailed, ManagedProcess, main_cli # noqa: E402
import tai # noqa: E402
async def _drain_forever(proc):
"""Same requirement as tests/plexer.py's helper of the same name (see
that file for the full postmortem): only needed when the spawned process
itself runs a second relay loop that would otherwise self-deadlock on its
own unread output -- i.e. once bash execs into `tai.py --plexer-wrap`.
Plain `bash -i` that never wraps doesn't need this; those tests read
directly instead."""
try:
while True:
chunk = await proc._read_chunk()
if not chunk:
await asyncio.sleep(0.05)
except asyncio.CancelledError:
pass
class _BashrcHookTestBase(TestCase):
"""Shared fixture for every test that installs the real plexer hook into
a real .bashrc: a throwaway $HOME, the real tai.py binary copied into
$HOME/.local/bin via tai.op_binary (exactly what `tai.py --install` does
on a real machine), and the hook itself via tai.op_plexer_hook -- the
same functions a real install runs, not a hand-rolled substitute."""
mode = "pty"
timeout = 30.0
async def setup(self):
if os.environ.get("TAI_CONTAINER_TEST") != "1":
raise TestAssertionFailed(
"refusing to run (TAI_CONTAINER_TEST is not set): this test installs a real "
"hook into $HOME/.bashrc and must only ever run inside the disposable test "
"container (see tests/docker/Dockerfile), never against a real machine's home"
)
self.tmp_home = tempfile.mkdtemp(prefix="tai-test-home-")
os.makedirs(os.path.join(self.tmp_home, ".local", "bin"), exist_ok=True)
real_home = os.environ.get("HOME")
os.environ["HOME"] = self.tmp_home
try:
tai.op_binary("install", self.tmp_home)
tai.op_plexer_hook("install", self.tmp_home)
finally:
if real_home is not None:
os.environ["HOME"] = real_home
else:
os.environ.pop("HOME", None)
self._drain_task = None
async def _spawn_bash(self, args, mode=None, env_overrides=None):
env = self.build_env()
env.update(env_overrides or {})
proc = ManagedProcess(["bash"] + list(args), mode=mode or self.mode, env=env, on_line=self._on_line)
await proc.start()
self._processes.append(proc)
return proc
def _sock_matches(self):
return glob.glob(os.path.join(self.tmp_home, ".tai", "plexer", "*.sock"))
async def teardown(self):
if self._drain_task is not None:
self._drain_task.cancel()
shutil.rmtree(self.tmp_home, ignore_errors=True)
class BashrcInstallAndAutoWrapTest(_BashrcHookTestBase):
"""The actual point of building a container for this: a real `bash -i`,
with a real installed hook in its real $HOME/.bashrc, auto-wraps into a
real NativePlexerBackend with no human and no flag telling it to."""
name = "bashrc-install-and-auto-wrap"
async def run(self):
proc = await self._spawn_bash(["-i"], env_overrides={"HOME": self.tmp_home})
self._drain_task = asyncio.ensure_future(_drain_forever(proc))
sock_path = None
for _ in range(50):
matches = self._sock_matches()
if matches:
sock_path = matches[0]
break
await asyncio.sleep(0.2)
if not sock_path:
raise TestAssertionFailed(
"bash -i never auto-wrapped into a plexer backend via the installed .bashrc hook"
)
controller = tai.NativePlexerController(sock_path)
await asyncio.sleep(0.3)
status = controller.status()
controller.send_keys("echo $TAI_PLEXER_SOCKET", enter=True)
await asyncio.sleep(0.4)
pane = controller.read_pane(50)
return {"sock_path": sock_path, "pane": pane, "status": status}
async def verify(self, result):
if result["sock_path"] not in result["pane"]:
raise TestAssertionFailed(
"the auto-wrapped inner shell's $TAI_PLEXER_SOCKET did not match the backend's "
"own socket path: %r" % result["pane"][-300:]
)
if not result["status"].get("pid") or result["status"]["pid"] <= 0:
raise TestAssertionFailed("status() did not report a live child pid after auto-wrap")
class AntiRecursionTest(_BashrcHookTestBase):
"""Already-wrapped ($TAI_PLEXER_ACTIVE=1) must never wrap again."""
name = "bashrc-hook-anti-recursion"
async def run(self):
proc = await self._spawn_bash(
["-i"], env_overrides={"HOME": self.tmp_home, "TAI_PLEXER_ACTIVE": "1"}
)
await proc.send_line("echo anti_recursion_marker_ok")
await proc.wait_for_pattern(re.compile(r"anti_recursion_marker_ok"), timeout=8.0)
await asyncio.sleep(0.3)
return {"sock_matches": self._sock_matches()}
async def verify(self, result):
if result["sock_matches"]:
raise TestAssertionFailed(
"a plexer socket was created even though TAI_PLEXER_ACTIVE was already set -- "
"recursive wrap was not prevented"
)
class TmuxActiveSkipTest(_BashrcHookTestBase):
"""An already-active tmux pane ($TMUX set) must also never wrap."""
name = "bashrc-hook-tmux-active-skip"
async def run(self):
proc = await self._spawn_bash(
["-i"], env_overrides={"HOME": self.tmp_home, "TMUX": "dummy-session:0.0"}
)
await proc.send_line("echo tmux_skip_marker_ok")
await proc.wait_for_pattern(re.compile(r"tmux_skip_marker_ok"), timeout=8.0)
await asyncio.sleep(0.3)
return {"sock_matches": self._sock_matches()}
async def verify(self, result):
if result["sock_matches"]:
raise TestAssertionFailed(
"a plexer socket was created even though $TMUX was already set -- tmux should "
"have been left in sole control of the session"
)
class NonInteractiveSkipTest(_BashrcHookTestBase):
"""A non-interactive shell must never wrap, even if it explicitly sources
the hook (isolates the hook's own `case $- in *i*)` guard from bash's
separate, unrelated default of not reading ~/.bashrc for `-c` at all)."""
name = "bashrc-hook-non-interactive-skip"
mode = "pipe"
async def run(self):
proc = await self._spawn_bash(
["-c", 'source "$HOME/.bashrc" 2>&1; echo marker_non_interactive_ok'],
mode="pipe",
env_overrides={"HOME": self.tmp_home},
)
output = await proc.drain_until_eof(timeout=10.0)
returncode = await proc.wait(timeout=5.0)
return {"output": output, "returncode": returncode}
async def verify(self, result):
if "marker_non_interactive_ok" not in result["output"]:
raise TestAssertionFailed(
"a non-interactive `bash -c` that explicitly sourced the hook never reached past "
"it: %r" % result["output"][-500:]
)
if result["returncode"] != 0:
raise TestAssertionFailed(
"non-interactive shell exited %r after sourcing the hook" % result["returncode"]
)
if self._sock_matches():
raise TestAssertionFailed(
"a plexer socket was created from a non-interactive shell -- the "
"`case $- in *i*)` guard failed"
)
class TmuxControllerLiveTest(TestCase):
"""Drives TmuxPlexerController against a real `tmux new-session -d`, not
a mock -- the one TmuxPlexerController code path tests/plexer.py cannot
cover, since that file only ever exercises NativePlexerBackend."""
name = "bashrc-tmux-controller-live"
timeout = 20.0
async def setup(self):
if not shutil.which("tmux"):
raise TestAssertionFailed(
"tmux is not installed -- required to test TmuxPlexerController against a real session"
)
self.session_name = "tai-test-%d" % os.getpid()
async def run(self):
create = await asyncio.create_subprocess_exec(
"tmux", "new-session", "-d", "-s", self.session_name, "-x", "80", "-y", "24",
)
await create.wait()
if create.returncode != 0:
raise TestAssertionFailed("tmux new-session -d failed (returncode %r)" % create.returncode)
pane = "%s:0.0" % self.session_name
controller = tai.TmuxPlexerController(pane)
status = controller.status()
if status.get("backend") != "tmux":
raise TestAssertionFailed("status() did not report backend=tmux: %r" % status)
controller.send_keys("echo tmux_controller_marker_ok", enter=True)
await asyncio.sleep(0.6)
pane_text = controller.read_pane(50)
if "tmux_controller_marker_ok" not in pane_text:
raise TestAssertionFailed(
"send_keys/read_pane against a real tmux session failed: %r" % pane_text[-300:]
)
controller.type_text("echo tmux_typed_marker_ok", delay=0.01, enter=True)
await asyncio.sleep(0.6)
pane_text_2 = controller.read_pane(50)
if "tmux_typed_marker_ok" not in pane_text_2:
raise TestAssertionFailed(
"type_text against a real tmux session failed: %r" % pane_text_2[-300:]
)
bogus = tai.TmuxPlexerController("nonexistent-session-%d:99.99" % os.getpid())
raised = False
try:
bogus.status()
except tai.PlexerError:
raised = True
return {"raised": raised}
async def verify(self, result):
if not result["raised"]:
raise TestAssertionFailed(
"TmuxPlexerController against a nonexistent pane should raise PlexerError, not "
"silently succeed"
)
async def teardown(self):
kill = await asyncio.create_subprocess_exec(
"tmux", "kill-session", "-t", self.session_name,
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL,
)
await kill.wait()
class PlexerHookInstallUninstallRoundTripTest(TestCase):
"""Purely deterministic, no subprocess, no real $HOME -- the install
functions are tested directly against a throwaway file path, so this one
is safe to run anywhere, not just in the container."""
name = "bashrc-hook-install-uninstall-roundtrip"
timeout = 10.0
async def setup(self):
self.tmp_dir = tempfile.mkdtemp(prefix="tai-test-bashrc-")
self.bashrc_path = os.path.join(self.tmp_dir, ".bashrc")
with open(self.bashrc_path, "w", encoding="utf-8") as handle:
handle.write("# pre-existing user content\nexport FOO=bar\n")
async def run(self):
with open(self.bashrc_path, encoding="utf-8") as handle:
original = handle.read()
installed_first = tai.upsert_plexer_bashrc_block(self.bashrc_path)
present_after_first = tai.plexer_hook_present(self.bashrc_path)
with open(self.bashrc_path, encoding="utf-8") as handle:
content_after_first = handle.read()
installed_second = tai.upsert_plexer_bashrc_block(self.bashrc_path)
with open(self.bashrc_path, encoding="utf-8") as handle:
content_after_second = handle.read()
backup_path = self.bashrc_path + ".bak-tai"
backup_exists = os.path.exists(backup_path)
backup_content = None
if backup_exists:
with open(backup_path, encoding="utf-8") as handle:
backup_content = handle.read()
removed = tai.remove_plexer_bashrc_block(self.bashrc_path)
present_after_remove = tai.plexer_hook_present(self.bashrc_path)
with open(self.bashrc_path, encoding="utf-8") as handle:
content_after_remove = handle.read()
reinstalled = tai.upsert_plexer_bashrc_block(self.bashrc_path)
present_after_reinstall = tai.plexer_hook_present(self.bashrc_path)
return {
"original": original,
"installed_first": installed_first,
"present_after_first": present_after_first,
"content_after_first": content_after_first,
"installed_second": installed_second,
"content_after_second": content_after_second,
"backup_exists": backup_exists,
"backup_content": backup_content,
"removed": removed,
"present_after_remove": present_after_remove,
"content_after_remove": content_after_remove,
"reinstalled": reinstalled,
"present_after_reinstall": present_after_reinstall,
}
async def verify(self, result):
if not result["installed_first"]:
raise TestAssertionFailed("first upsert_plexer_bashrc_block() reported no change on a clean file")
if not result["present_after_first"]:
raise TestAssertionFailed("plexer_hook_present() returned false right after installing")
if "export FOO=bar" not in result["content_after_first"]:
raise TestAssertionFailed("installing the hook clobbered pre-existing .bashrc content")
if result["installed_second"]:
raise TestAssertionFailed("upsert_plexer_bashrc_block() was not idempotent -- reported a change on a second call")
if result["content_after_second"] != result["content_after_first"]:
raise TestAssertionFailed("a second install duplicated the hook block instead of being a no-op")
if not result["backup_exists"]:
raise TestAssertionFailed("no .bak-tai backup was created before modifying .bashrc")
if result["backup_content"] != result["original"]:
raise TestAssertionFailed("the .bak-tai backup does not match the original pre-install content")
if not result["removed"]:
raise TestAssertionFailed("remove_plexer_bashrc_block() reported no change when the hook was present")
if result["present_after_remove"]:
raise TestAssertionFailed("plexer_hook_present() still true after removal")
if "export FOO=bar" not in result["content_after_remove"]:
raise TestAssertionFailed("uninstalling the hook lost pre-existing .bashrc content")
if tai.PLEXER_BASHRC_MARK_BEGIN in result["content_after_remove"]:
raise TestAssertionFailed("uninstall left hook marker text behind")
if not result["reinstalled"]:
raise TestAssertionFailed("could not reinstall the hook after a clean uninstall")
if not result["present_after_reinstall"]:
raise TestAssertionFailed("hook not present after reinstalling post-uninstall")
async def teardown(self):
shutil.rmtree(self.tmp_dir, ignore_errors=True)
ALL_CASES = [
BashrcInstallAndAutoWrapTest(),
AntiRecursionTest(),
TmuxActiveSkipTest(),
NonInteractiveSkipTest(),
TmuxControllerLiveTest(),
PlexerHookInstallUninstallRoundTripTest(),
]
if __name__ == "__main__":
sys.exit(main_cli(ALL_CASES, default_log_dir=str(Path(__file__).resolve().parent / "logs")))
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""tests/container.py — run the full test suite inside a throwaway container.
Two things can only be tested honestly inside a disposable environment, not
on the machine actually running Claude Code: installing the plexer hook into
a real ~/.bashrc and watching a real `bash -i` actually get wrapped by it
(tests/bashrc.py), and driving TmuxPlexerController against a real tmux
session. This is the harness for that -- not a replacement for test_seal.py,
test_tai.py, or tests/plexer.py, which also run inside the same container
first, so "did it work in a clean environment" is answered for everything,
not just the two new container-only tests. tests/rag.py runs too, but as a
best-effort check, not a required one -- see BEST_EFFORT_SUITE below for why.
Engine detection deliberately reuses tai.container_engine() (podman first,
then docker, same as the project's own sandbox feature) rather than
reimplementing "which one is installed" a second way. docker-compose.yml at
the repo root exists for a human who wants `docker compose run --rm
tai-test` / `podman compose run --rm tai-test`; this script drives plain
build/run directly so the automated path never depends on whether a compose
implementation happens to be installed on top of the engine itself.
Usage:
python3 tests/container.py # build the image, run everything inside it
python3 tests/container.py --inside # (used by the container itself) actually run the suite
"""
import argparse
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
import tai # noqa: E402
IMAGE_TAG = "tai-test:latest"
# Run in this order: cheapest/most-fundamental first, so a basic break fails
# fast instead of burning time on tmux/bashrc tests that assume the basics
# already work. These must all pass for the container run to count as green.
REQUIRED_SUITE = [
("test_seal.py", ["python3", "test_seal.py"]),
("test_tai.py", ["python3", "-m", "unittest", "test_tai", "-v"]),
("tests/plexer.py", ["python3", "tests/plexer.py"]),
("tests/bashrc.py", ["python3", "tests/bashrc.py"]),
]
# tests/rag.py's RagIndexAndSearchTest needs a real, working LLM backend.
# Confirmed by direct investigation (not assumed): inside this disposable
# container there is no opencode/claude/codex/gemini/grok CLI (the host's
# own successful run used a locally authenticated `opencode` CLI that has
# no business being baked into a throwaway test image), and the remaining
# free HTTP backends fail for reasons that are about the shared sandbox
# network, not this code -- openrouter's free tier returned "HTTP 401: No
# cookie auth credentials found" for every model (anonymous free-tier auth
# needs a browser cookie session this container doesn't have), and
# pollinations returned "HTTP 429: Queue full for IP: ...: 1 requests
# already queued" (its free tier's per-IP concurrency cap, already
# exhausted by other traffic sharing this sandbox's egress address). Both
# reproduced with a bare urllib POST outside of tai.py entirely, so this is
# not a tai.py bug to fix. It's run and reported like everything else, but
# never fails the overall container result -- tests/rag.py already runs
# (and passes) against the host directly, which is where a real LLM
# backend actually exists; see tests/README.md's "test with the real AI,
# accept the flakes" rule, which this is really just a container-flavored
# instance of.
BEST_EFFORT_SUITE = [
("tests/rag.py", ["python3", "tests/rag.py"]),
]
def run_outside():
engine = tai.container_engine()
if not engine:
print("no container engine found (checked podman, then docker) -- refusing to run the "
"container-only test suite. Install either one to run tests/bashrc.py and the "
"TmuxPlexerController live test; everything else already ran on the host directly.")
return 1
print("using container engine: %s" % engine, flush=True)
build = subprocess.run([engine, "build", "-t", IMAGE_TAG, "-f", "tests/docker/Dockerfile", "."], cwd=str(ROOT))
if build.returncode != 0:
print("image build failed", flush=True)
return build.returncode
run = subprocess.run([engine, "run", "--rm", IMAGE_TAG, "python3", "tests/container.py", "--inside"], cwd=str(ROOT))
return run.returncode
def _run_suite(suite):
results = []
for name, cmd in suite:
print("\n=== %s ===" % name)
done = subprocess.run(cmd, cwd=str(ROOT))
results.append((name, done.returncode == 0))
print("--- %s: %s ---" % (name, "PASS" if done.returncode == 0 else "FAIL"))
return results
def run_inside():
required = _run_suite(REQUIRED_SUITE)
best_effort = _run_suite(BEST_EFFORT_SUITE)
print("\n=== container test summary ===")
for name, passed in required:
print("%-6s %s" % ("PASS" if passed else "FAIL", name))
for name, passed in best_effort:
print("%-6s %s (best-effort: needs a real LLM backend, see comment in this file)" % ("PASS" if passed else "FAIL", name))
failed_required = [name for name, passed in required if not passed]
failed_best_effort = [name for name, passed in best_effort if not passed]
total = len(required) + len(best_effort)
passed_count = total - len(failed_required) - len(failed_best_effort)
print("%d/%d passed" % (passed_count, total), end="")
if failed_best_effort:
print(" (ignoring best-effort failures: %s)" % ", ".join(failed_best_effort))
else:
print("")
if failed_required:
print("REQUIRED tests failed: %s" % ", ".join(failed_required))
return 1
return 0
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--inside", action="store_true", help="internal: actually run the suite (used by the container itself)")
args = parser.parse_args()
if args.inside:
return run_inside()
return run_outside()
if __name__ == "__main__":
sys.exit(main())
+30
View File
@@ -0,0 +1,30 @@
# Minimal, disposable environment for the tests that cannot safely or
# meaningfully run against a real machine: anything that installs the
# plexer hook into a real ~/.bashrc, or needs a real tmux session to test
# TmuxPlexerController against. Built fresh per run (see tests/container.py);
# nothing here is meant to persist, which is the entire point -- these tests
# get to do things no test should ever do to the machine actually running
# Claude Code.
FROM python:3.11-slim
# bash: the thing being wrapped. tmux: TmuxPlexerController's real backend.
# procps: `ps`, used by tests/plexer.py and tests/bashrc.py to prove a
# process is actually gone, not just assumed gone. git: sysinfo/create_skill
# paths touch it; harmless to have for a "simulate everything" image.
RUN apt-get update \
&& apt-get install -y --no-install-recommends bash tmux procps git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . /app
# Safety marker, not a feature flag: tests/bashrc.py refuses to install a
# real hook into $HOME/.bashrc unless this is set, so a copy-paste of this
# test file running directly on a developer's machine fails loud instead of
# quietly rewriting their actual shell startup.
ENV TAI_CONTAINER_TEST=1
# No ENTRYPOINT/CMD here on purpose -- tests/container.py always passes an
# explicit command (python3 tests/container.py --inside ...), so this image
# has one job and docker-compose.yml's `command:` is the single source of
# truth for what that job is, not two places that can drift apart.
+289
View File
@@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""tests/plexer.py — integration tests for tai's Plexer subsystem (NativePlexerBackend).
Deterministic throughout: this drives tai.py --plexer-wrap, a plumbing mode
that forks a real shell onto a real pty with no LLM involvement at all, so
"test with the real AI" (tests/README.md) doesn't apply here the way it does
to tests/rag.py — there is no model decision anywhere in this path to test.
What's real instead is the pty/signal/socket mechanics themselves: the whole
point of this file is that it exercises the actual `tai.py --plexer-wrap`
binary under a real pty (via tai_test.py's own pty mode — testing a pty
wrapper by driving it through another pty), not mocked termios/signal calls.
A real bug was caught by actually running this, not by reading the code: an
external SIGTERM (and SIGHUP) does not terminate an interactive bash running
as a pty.fork() session leader in this environment -- verified with a bare
pty.fork()+bash+os.kill script, independent of any tai.py code.
NativePlexerBackend's Ctrl+A,q shortcut uses SIGKILL specifically because of
this, not out of caution -- SIGTERM was tried first and silently did nothing
for ~10s in a real run.
"""
import glob
import os
import shutil
import struct
import sys
import tempfile
import asyncio
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from tai_test import TestCase, TestAssertionFailed, main_cli # noqa: E402
import tai # noqa: E402
async def _drain_forever(proc):
"""A real terminal emulator is always reading, continuously, for as long
as it's open -- that's what keeps a pty's output buffer from ever
backing up. This test harness has to replicate that explicitly: without
a background task doing this, NativePlexerBackend's relay loop can block
on its own os.write(stdout_fd, ...) the moment a command's echoed output
is large enough to fill the (unread) outer pty buffer, which stalls the
*entire* relay loop -- including replying to later control-socket calls,
which is what made this look like a control-protocol deadlock the first
time this was debugged, when the actual missing piece was this drain.
A second real bug, found immediately after fixing the first: once the
wrapped shell exits, the stream hits EOF and read() returns b"" forever
without blocking -- so a plain `while True: await proc._read_chunk()`
busy-spins at that point instead of idling, and spins hard enough to
starve the event loop so badly that even asyncio.wait_for's own timeout
machinery stops getting a fair turn. Hence the explicit EOF check."""
try:
while True:
chunk = await proc._read_chunk()
if not chunk:
await asyncio.sleep(0.05)
except asyncio.CancelledError:
pass
class NativePlexerBackendTest(TestCase):
"""Spawns `tai.py --plexer-wrap` under a real pty (tai_test's pty mode),
then drives it as an external controller would: over its control socket,
plus raw keystrokes written straight to the outer pty master to exercise
the Ctrl+A prefix-key path exactly as a human's terminal would deliver it."""
name = "plexer-native-backend"
mode = "pty"
timeout = 30.0
async def setup(self):
self.tmp_home = tempfile.mkdtemp(prefix="tai-test-home-")
self.env_overrides = {"TAI_HOME": self.tmp_home, "TAI_PASSPHRASE": ""}
async def run(self):
proc = await self.spawn(extra_args=["--plexer-wrap"])
self._drain_task = asyncio.ensure_future(_drain_forever(proc))
sock_path = None
for _ in range(50):
matches = glob.glob(os.path.join(self.tmp_home, "plexer", "*.sock"))
if matches:
sock_path = matches[0]
break
await asyncio.sleep(0.2)
if not sock_path:
raise TestAssertionFailed("NativePlexerBackend never created its control socket")
controller = tai.NativePlexerController(sock_path)
await asyncio.sleep(0.3)
status = controller.status()
child_pid = status.get("pid")
if not child_pid or child_pid <= 0:
raise TestAssertionFailed("status() did not report a valid child pid: %r" % status)
controller.send_keys("echo plexer_test_marker", enter=True)
await asyncio.sleep(0.4)
after_send = controller.read_pane(50)
if "plexer_test_marker" not in after_send:
raise TestAssertionFailed("send_keys/read_pane round trip failed: %r" % after_send[-300:])
controller.type_text("echo typed_marker_ok", delay=0.01, enter=True)
await asyncio.sleep(0.4)
after_type = controller.read_pane(50)
if "typed_marker_ok" not in after_type:
raise TestAssertionFailed("type_text did not reach the shell: %r" % after_type[-300:])
import fcntl
import termios
fcntl.ioctl(proc._master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", 17, 66, 0, 0))
os.kill(proc.proc.pid, tai.signal.SIGWINCH)
await asyncio.sleep(0.4)
controller.send_keys("stty size", enter=True)
await asyncio.sleep(0.4)
after_resize = controller.read_pane(50)
if "17 66" not in after_resize:
raise TestAssertionFailed("SIGWINCH resize was not forwarded to the inner shell: %r" % after_resize[-300:])
controller.send_keys("echo contains_a_d_character", enter=True)
await asyncio.sleep(0.3)
after_plain = controller.read_pane(50)
if "contains_a_d_character" not in after_plain:
raise TestAssertionFailed("a plain command containing the letter 'd' was mangled by prefix-key handling")
os.write(proc._master_fd, tai.PLEXER_PREFIX_KEY)
os.write(proc._master_fd, b"q")
await proc.wait(timeout=10.0)
await asyncio.sleep(0.2)
socket_survived = os.path.exists(sock_path)
import subprocess
still_running = subprocess.run(["ps", "-p", str(child_pid)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0
log_matches = glob.glob(os.path.join(self.tmp_home, "plexer", "*.log"))
log_has_marker = False
if log_matches:
with open(log_matches[0], "rb") as handle:
log_has_marker = b"plexer_test_marker" in handle.read()
return {
"socket_survived": socket_survived,
"still_running": still_running,
"log_has_marker": log_has_marker,
}
async def verify(self, result):
if result["socket_survived"]:
raise TestAssertionFailed("control socket file was not cleaned up after Ctrl+A,q")
if result["still_running"]:
raise TestAssertionFailed("the wrapped shell process was still alive after Ctrl+A,q (leaked process)")
if not result["log_has_marker"]:
raise TestAssertionFailed("the continuous session log did not capture the session's actual output")
async def teardown(self):
self._drain_task.cancel()
shutil.rmtree(self.tmp_home, ignore_errors=True)
class NativePlexerBackendRobustnessTest(TestCase):
"""Targets three specific bugs found and fixed during a deliberate review
pass after NativePlexerBackendTest first went green -- a happy-path test
passing does not mean the code is reliable, and all three of these would
have sailed through that test unnoticed:
1. File permissions: the control socket and the session log (which
captures the ENTIRE terminal session verbatim -- anything typed,
including passwords) were being created with umask-default
permissions, often world-readable/connectable on a multi-user box.
2. The control protocol's server side did a single recv() instead of
looping like the client side already did, silently truncating any
request too large for one read -- untestable with short demo strings,
which is exactly why the first test never caught it.
3. A malformed (non-JSON-object) request on the control socket was not
defensively handled and would have propagated an uncaught exception
out of the relay loop, crashing the user's entire live wrapped
terminal session over one bad control message.
4. A genuine deadlock: plexer_send used to write its text directly into
the pty master from inside the control-request handler. For a large
payload on an echo-enabled pty, that write can block waiting for
buffer space that only frees up once something reads the echoed
output back out -- which is this very relay loop's own job, and it
was the one blocked. Fixed by queuing all writes-to-child and only
ever draining them from the main select() loop, cooperatively with
reading the child's output, never as a blocking write from inside
request handling.
"""
name = "plexer-native-backend-robustness"
mode = "pty"
timeout = 30.0
async def setup(self):
self.tmp_home = tempfile.mkdtemp(prefix="tai-test-home-")
self.env_overrides = {"TAI_HOME": self.tmp_home, "TAI_PASSPHRASE": ""}
async def run(self):
proc = await self.spawn(extra_args=["--plexer-wrap"])
self._drain_task = asyncio.ensure_future(_drain_forever(proc))
sock_path = None
for _ in range(50):
matches = glob.glob(os.path.join(self.tmp_home, "plexer", "*.sock"))
if matches:
sock_path = matches[0]
break
await asyncio.sleep(0.2)
if not sock_path:
raise TestAssertionFailed("NativePlexerBackend never created its control socket")
controller = tai.NativePlexerController(sock_path)
await asyncio.sleep(0.3)
plexer_dir_path = os.path.dirname(sock_path)
log_matches = glob.glob(os.path.join(plexer_dir_path, "*.log"))
if not log_matches:
raise TestAssertionFailed("no log file found to check permissions on")
log_path = log_matches[0]
dir_mode = os.stat(plexer_dir_path).st_mode & 0o777
sock_mode = os.stat(sock_path).st_mode & 0o777
log_mode = os.stat(log_path).st_mode & 0o777
# Large payload: bigger than the control protocol's 65536-byte recv
# chunk size, to actually exercise the server-side framing loop
# rather than the common case that fits in one read.
needle_head = "LARGE_PAYLOAD_START_"
needle_tail = "_LARGE_PAYLOAD_END"
padding = "x" * 90000
large_text = "echo %s%s%s" % (needle_head, padding, needle_tail)
controller.send_keys(large_text, enter=True)
await asyncio.sleep(0.6)
large_result = controller.read_pane(2000)
# Malformed control request: connect directly to the socket (bypassing
# the well-formed NativePlexerController entirely) and send something
# that is valid JSON but not an object, which the old code would have
# handed straight to request.get("cmd") and crashed on.
import socket as socket_module
import json as json_module
raw_sock = socket_module.socket(socket_module.AF_UNIX, socket_module.SOCK_STREAM)
raw_sock.settimeout(5)
raw_sock.connect(sock_path)
raw_sock.sendall(b"[1, 2, 3]\n")
malformed_reply_raw = raw_sock.recv(65536)
raw_sock.close()
malformed_reply = json_module.loads(malformed_reply_raw.decode("utf-8", "replace"))
# The backend must still be alive and responsive after that malformed
# request -- this is the actual point of the test, not the error
# shape above.
status_after = controller.status()
controller.send_keys("exit", enter=True)
await proc.wait(timeout=10.0)
return {
"dir_mode": dir_mode,
"sock_mode": sock_mode,
"log_mode": log_mode,
"large_result": large_result,
"malformed_reply": malformed_reply,
"status_after_pid": status_after.get("pid"),
}
async def verify(self, result):
if result["dir_mode"] != 0o700:
raise TestAssertionFailed("plexer dir mode is %o, expected 0700 (owner-only)" % result["dir_mode"])
if result["sock_mode"] != 0o600:
raise TestAssertionFailed("control socket mode is %o, expected 0600 (owner-only)" % result["sock_mode"])
if result["log_mode"] != 0o600:
raise TestAssertionFailed("session log mode is %o, expected 0600 (owner-only) -- it captures everything typed" % result["log_mode"])
large = result["large_result"]
if "LARGE_PAYLOAD_START_" not in large or "_LARGE_PAYLOAD_END" not in large:
raise TestAssertionFailed("large (>65536 byte) plexer_send payload was truncated by the control protocol")
if result["malformed_reply"].get("ok") is not False:
raise TestAssertionFailed("a malformed control request did not come back as a clean error: %r" % result["malformed_reply"])
if not result["status_after_pid"] or result["status_after_pid"] <= 0:
raise TestAssertionFailed("backend did not survive the malformed request -- a bad control message must never crash the whole wrapped session")
async def teardown(self):
self._drain_task.cancel()
shutil.rmtree(self.tmp_home, ignore_errors=True)
ALL_CASES = [NativePlexerBackendTest(), NativePlexerBackendRobustnessTest()]
if __name__ == "__main__":
sys.exit(main_cli(ALL_CASES, default_log_dir=str(Path(__file__).resolve().parent / "logs")))
+268
View File
@@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""tests/rag.py — integration tests for tai's chunked FTS5/BM25 RAG system.
Built on tai_test.py (see tests/README.md for the conventions this file
follows). Two test cases, deliberately different in how deterministic they
are:
- InteractiveReplSmokeTest drives a persistent REPL over a real pty with
nothing but REPL slash commands (/skills, /search). Slash commands dispatch
tools directly, with no LLM call involved, so this test is fully
deterministic and fast — it exists to prove the pty transport, the
prompt-detection turn boundary, and the REPL plumbing all actually work,
independent of model behavior.
- RagIndexAndSearchTest is the real ask: index a sample of this project's own
research/ archive into a fresh, isolated vault, then verify it comes back
through search. Indexing (asking the agent to call record_save) goes
through the LLM, which is inherently probabilistic — a free, rotating
backend (see README.md's "Backends" section) can pick a different model
per request. This test accepts that honestly: it reports how many of the
sampled files were actually indexed rather than demanding all-or-nothing,
and only hard-fails if indexing came back empty or the deterministic
search step (itself a REPL slash command, no LLM) doesn't find anything
that was actually indexed.
"""
import difflib
import os
import re
import shutil
import sqlite3
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from tai_test import TestCase, TestAssertionFailed, main_cli # noqa: E402
ROOT = Path(__file__).resolve().parent.parent
RESEARCH_DIR = ROOT / "research"
REPL_PROMPT_RE = re.compile(r"tai\[[^\]]*\]\s*[›>]")
SAMPLE_SIZE = int(os.environ.get("RAG_TEST_SAMPLE_SIZE", "5"))
SOURCE_URL_RE = re.compile(r"\|\s*Source URL\s*\|\s*(\S+)\s*\|")
def pick_sample_files(limit=SAMPLE_SIZE):
# Only source dossiers/citation cards: each has exactly one "Source URL"
# metadata field, which is what gives us a per-file, globally-unique
# fingerprint. Conclusions cite several URLs each, so they don't fit
# this scheme and are excluded.
files = sorted(
p for p in RESEARCH_DIR.rglob("*.md")
if p.name not in ("README.md", "INDEX.md") and "conclusions" not in p.parts
)
return files[:limit]
class InteractiveReplSmokeTest(TestCase):
"""Deterministic: pure REPL slash commands, no LLM call, proves the pty transport."""
name = "repl-pty-smoke"
mode = "pty"
timeout = 30.0
async def setup(self):
self.tmp_home = tempfile.mkdtemp(prefix="tai-test-home-")
self.env_overrides = {"TAI_HOME": self.tmp_home, "TAI_PASSPHRASE": ""}
async def run(self):
proc = await self.spawn(extra_args=["--profile", "smoketest"])
await proc.wait_for_pattern(REPL_PROMPT_RE, timeout=15.0)
await proc.send_line("/skills")
skills_reply = await proc.wait_for_pattern(REPL_PROMPT_RE, timeout=15.0)
await proc.send_line("/search nonexistent-term-xyz")
search_reply = await proc.wait_for_pattern(REPL_PROMPT_RE, timeout=15.0)
await proc.send_line("/quit")
await proc.wait(timeout=10.0)
return {"skills_reply": skills_reply, "search_reply": search_reply}
async def verify(self, result):
if "skills" not in result["skills_reply"] and "no skills" not in result["skills_reply"]:
raise TestAssertionFailed("/skills produced no recognizable output: %r" % result["skills_reply"][:300])
if "no matches" not in result["search_reply"] and "error" in result["search_reply"].lower():
raise TestAssertionFailed("/search errored instead of reporting no matches: %r" % result["search_reply"][:300])
async def teardown(self):
shutil.rmtree(self.tmp_home, ignore_errors=True)
class RagIndexAndSearchTest(TestCase):
"""The real ask, proven with per-file traceability, not just 'something came back'.
For each sampled file this test:
1. extracts that file's Source URL as a fingerprint, and confirms it's
not shared by any other file in the sample (a non-unique fingerprint
would make a search hit ambiguous as "proof");
2. asks the agent to record_save it, exactly as before;
3. reads the saved record straight out of the vault DB and diffs it
against what was actually sent (not the whole file — what we asked
the model to save verbatim), catching silent paraphrasing/truncation;
4. confirms the fingerprint survived chunking, scoped to that record's
own chunks specifically (not "some chunk somewhere");
5. runs a /search for that one file's fingerprint and requires the
reply to name that exact record's mem: id.
Every step is reported per file (see verify()'s failure message and the
log), so a partial result is auditable rather than a single opaque
pass/fail.
"""
name = "rag-index-and-search"
mode = "pipe"
timeout = 60.0 * 8 # two LLM round trips per sampled file now (save + per-file search)
async def setup(self):
self.tmp_home = tempfile.mkdtemp(prefix="tai-test-home-")
self.env_overrides = {"TAI_HOME": self.tmp_home, "TAI_PASSPHRASE": ""}
self.files = pick_sample_files()
if not self.files:
raise TestAssertionFailed("no documents found under research/ to index — did research/README.md's archive get built?")
async def run(self):
fingerprints = self._extract_and_verify_unique_fingerprints(self.files)
per_file = []
for path, fingerprint in fingerprints:
sent_content = path.read_text(encoding="utf-8", errors="replace")[:3000]
title = path.relative_to(RESEARCH_DIR).as_posix()
prompt = (
"Call the record_save tool right now with kind=\"research\", "
"title=\"%s\", and content set to exactly the text below, verbatim. "
"Do not summarize it, do not explain yourself, just call the tool once.\n\n%s"
% (title, sent_content)
)
proc = await self.spawn(extra_args=["--yes", "--profile", "ragtest", prompt])
await proc.drain_until_eof(timeout=90.0)
await proc.wait(timeout=10.0)
record = self._find_record_by_title(title)
fidelity = self._fidelity_ratio(sent_content, record["content"]) if record else 0.0
chunk_has_fingerprint = self._record_chunk_contains(record["id"], fingerprint) if record else False
per_file.append({
"title": title,
"fingerprint": fingerprint,
"saved": record is not None,
"record_id": record["id"] if record else None,
"fidelity": fidelity,
"chunk_has_fingerprint": chunk_has_fingerprint,
})
search_results = []
for entry in per_file:
if not entry["saved"]:
continue
proc = await self.spawn(extra_args=["--profile", "ragtest"], mode="pty")
await proc.wait_for_pattern(REPL_PROMPT_RE, timeout=15.0)
await proc.send_line("/search %s" % entry["fingerprint"])
reply = await proc.wait_for_pattern(REPL_PROMPT_RE, timeout=15.0)
await proc.send_line("/quit")
await proc.wait(timeout=10.0)
found_own_record = entry["record_id"] in reply
search_results.append({"title": entry["title"], "reply": reply, "found_own_record": found_own_record})
entry["search_found_own_record"] = found_own_record
return {"attempted": len(self.files), "per_file": per_file}
def _extract_and_verify_unique_fingerprints(self, files):
pairs = []
seen = {}
for path in files:
text = path.read_text(encoding="utf-8", errors="replace")
match = SOURCE_URL_RE.search(text)
if not match:
raise TestAssertionFailed("%s has no 'Source URL' metadata row — pick_sample_files() should have excluded it" % path)
fingerprint = match.group(1)
if fingerprint in seen:
raise TestAssertionFailed(
"fingerprint %r is not unique: shared by %s and %s — cannot use it as proof of traceability"
% (fingerprint, seen[fingerprint], path)
)
seen[fingerprint] = path
pairs.append((path, fingerprint))
return pairs
def _connect(self):
return sqlite3.connect(str(Path(self.tmp_home) / "memory.db"))
def _find_record_by_title(self, title):
conn = self._connect()
try:
row = conn.execute("SELECT id, content FROM records WHERE kind = 'research' AND title = ?", (title,)).fetchone()
finally:
conn.close()
return {"id": row[0], "content": row[1]} if row else None
def _record_chunk_contains(self, record_id, fingerprint):
conn = self._connect()
try:
rows = conn.execute("SELECT text FROM chunks WHERE parent_kind = 'record' AND parent_id = ?", (record_id,)).fetchall()
finally:
conn.close()
return any(fingerprint in row[0] for row in rows)
@staticmethod
def _fidelity_ratio(sent, saved):
if not saved:
return 0.0
return difflib.SequenceMatcher(None, sent, saved).ratio()
async def verify(self, result):
per_file = result["per_file"]
report_lines = [
"%-60s saved=%-5s fidelity=%.2f chunked=%-5s found_by_search=%-5s"
% (e["title"], e["saved"], e["fidelity"], e["chunk_has_fingerprint"], e.get("search_found_own_record"))
for e in per_file
]
report = "per-file breakdown:\n " + "\n ".join(report_lines)
saved_count = sum(1 for e in per_file if e["saved"])
if saved_count == 0:
raise TestAssertionFailed(
"indexed 0 of %d sampled files (checked the vault's records table directly) — this may be "
"free-backend flakiness, not a RAG bug; re-run before treating as a regression\n%s"
% (result["attempted"], report)
)
fully_proven = [
e for e in per_file
if e["saved"] and e["fidelity"] >= 0.90 and e["chunk_has_fingerprint"] and e.get("search_found_own_record")
]
if not fully_proven:
raise TestAssertionFailed(
"saved %d/%d file(s), but none passed the full chain (fidelity>=0.90 AND fingerprint survived "
"chunking AND /search for that file's own fingerprint named that exact record) — no file is "
"provably traceable end-to-end\n%s" % (saved_count, result["attempted"], report)
)
low_fidelity = [e for e in per_file if e["saved"] and e["fidelity"] < 0.90]
if low_fidelity:
raise TestAssertionFailed(
"%d/%d saved file(s) had content fidelity below 0.90 against what was actually sent — the model "
"paraphrased or truncated instead of saving verbatim: %s\n%s"
% (len(low_fidelity), saved_count, [e["title"] for e in low_fidelity], report)
)
not_chunked = [e for e in per_file if e["saved"] and not e["chunk_has_fingerprint"]]
if not_chunked:
raise TestAssertionFailed(
"%d/%d saved file(s) lost their fingerprint during chunking (present in the record but not in any "
"of that record's own chunks): %s\n%s" % (len(not_chunked), saved_count, [e["title"] for e in not_chunked], report)
)
not_found = [e for e in per_file if e["saved"] and not e.get("search_found_own_record")]
if not_found:
raise TestAssertionFailed(
"%d/%d saved file(s) did not come back when searching for their own unique fingerprint: %s\n%s"
% (len(not_found), saved_count, [e["title"] for e in not_found], report)
)
print(report)
async def teardown(self):
shutil.rmtree(self.tmp_home, ignore_errors=True)
ALL_CASES = [InteractiveReplSmokeTest(), RagIndexAndSearchTest()]
if __name__ == "__main__":
sys.exit(main_cli(ALL_CASES, default_log_dir=str(Path(__file__).resolve().parent / "logs")))