taicpp foundation: real tmux ask, SIGINT and terminate-hook tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -97,9 +97,7 @@ is the python "seal disabled" path.
|
||||
| readline (history recall, `complete_input`) | typed input is still recorded in `input_history` |
|
||||
| `start_scheduler`, `running_agents`, `probe_backend` | host hooks (`ReplHost::on_start`, `running_agents`, `backend_status`) |
|
||||
| `ensure_self_backup`, `snapshot_self`, `do_release` | script-specific, belong to release tooling |
|
||||
| tmux `ask` with a client attached (menu/popup answered) | error and dismissal paths are compared with python; the answered path is unverified |
|
||||
| `DebugLog` uncaught-exception hook | `std::set_terminate` analog of `sys.excepthook`, not exercised by a crash test |
|
||||
| SIGINT in the middle of a turn | `Interrupt` flag and `TurnInterrupted` exist; not driven by a live signal test |
|
||||
| `DebugLog` uncaught-exception hook | `std::set_terminate` analog; logged line carries the C++ type name, python's carries the python one |
|
||||
| Glow banner on a real tty | pieces are compared with python functions, the composed screen is not |
|
||||
|
||||
## Caps (all static)
|
||||
@@ -116,7 +114,8 @@ Run from `taicpp/`: `make -f tests/foundation.mk test` (needs only g++, sqlite3,
|
||||
| Test | What it proves |
|
||||
|---|---|
|
||||
| `tests/foundation_parity.py` + `foundation_probe` | one scenario replayed through tai.py and the C++ store: results, every table, the FTS index, the schema DDL and every profile file identical (timestamps and random ids masked). Pure functions (JSON, tags, settings rules, prompts, chunking, shell context against a fake /proc, flow validator and driver, Glow pieces), DebugLog sinks, REPL slash commands, native plexer against a scripted backend, tmux against a private server, and vault portability (each implementation reads the vault the other wrote) |
|
||||
| `tests/repl_persist.py` + `repl_probe` | the history bug: the REPL started repeatedly under a long-lived shell per pane resumes the same session file, another pane stays separate, a pinned `TAI_SESSION` is shared, an interrupted turn is resumed by `--continue`; every assertion reads the vault |
|
||||
| `tests/repl_persist.py` + `repl_probe` | the history bug: the REPL started repeatedly under a long-lived shell per pane resumes the same session file, another pane stays separate (also in real tmux panes), Ctrl-C mid-turn, an uncaught exception reaching the debug log, a pinned `TAI_SESSION` is shared, an interrupted turn is resumed by `--continue`; every assertion reads the vault |
|
||||
| `tests/tmux_ask.py` | tmux `ask` (menu, text popup, message, cancel, timeout) with a real client attached through a pty, both implementations, same replies |
|
||||
| `tests/foundation_test.cpp` | hash vectors, UTF-8 helpers, seal-enabled store paths with a stand-in seal, boot settings, dir defaults, turn files, plexer detection, line input |
|
||||
|
||||
`repl_probe` runs the real `Repl`, `Conversation` and `Store` with a scripted
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
#include "debuglog.h"
|
||||
#include <sys/stat.h>
|
||||
#include <algorithm>
|
||||
#include <cxxabi.h>
|
||||
#include <cstdlib>
|
||||
#include <exception>
|
||||
#include <typeinfo>
|
||||
#include <filesystem>
|
||||
#include "clock.h"
|
||||
#include "store.h"
|
||||
@@ -255,14 +257,20 @@ void DebugLog::prune() {
|
||||
void DebugLog::on_terminate() {
|
||||
DebugLog& log = Global();
|
||||
try {
|
||||
std::string what = "unknown";
|
||||
std::string what = "unknown", type = "exception";
|
||||
if (const std::type_info* t = abi::__cxa_current_exception_type()) {
|
||||
int status = 0;
|
||||
char* name = abi::__cxa_demangle(t->name(), nullptr, nullptr, &status);
|
||||
type = (status == 0 && name) ? name : t->name();
|
||||
std::free(name);
|
||||
}
|
||||
try {
|
||||
if (auto ep = std::current_exception()) std::rethrow_exception(ep);
|
||||
} catch (const std::exception& e) {
|
||||
what = e.what();
|
||||
} catch (...) {}
|
||||
Json m = Json::object();
|
||||
m.set("error", "terminate: " + what);
|
||||
m.set("error", type + ": " + Utf8::prefix(what, 300));
|
||||
log.event("ERROR", "app", "exception", "uncaught " + Utf8::prefix(what, 300), "", "", "", m);
|
||||
} catch (...) {}
|
||||
if (g_old_terminate) g_old_terminate();
|
||||
|
||||
@@ -30,6 +30,7 @@ test: all
|
||||
build/foundation_test
|
||||
python3 tests/foundation_parity.py
|
||||
python3 tests/repl_persist.py
|
||||
python3 tests/tmux_ask.py
|
||||
|
||||
clean-foundation:
|
||||
rm -rf $(FB) build/foundation_probe build/repl_probe build/foundation_test
|
||||
|
||||
@@ -98,6 +98,67 @@ class FakeBackend:
|
||||
os.unlink(self.path)
|
||||
|
||||
|
||||
class AttachedClient:
|
||||
"""A tmux client attached through a pty, so display-menu and display-popup have a client to draw on.
|
||||
The client's drawing is collected so a test can wait for a title to appear before it types."""
|
||||
|
||||
def __init__(self, server, session="t", cols=120, rows=40):
|
||||
import fcntl
|
||||
import pty
|
||||
import struct
|
||||
import termios
|
||||
self.output = b""
|
||||
self.lock = threading.Lock()
|
||||
pid, fd = pty.fork()
|
||||
if pid == 0:
|
||||
env = dict(server.env, TERM="xterm-256color")
|
||||
os.execvpe("tmux", ["tmux", "-f", "/dev/null", "attach-session", "-t", session], env)
|
||||
self.pid, self.fd = pid, fd
|
||||
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
||||
self.thread = threading.Thread(target=self.pump, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def pump(self):
|
||||
while True:
|
||||
try:
|
||||
chunk = os.read(self.fd, 65536)
|
||||
except OSError:
|
||||
return
|
||||
if not chunk:
|
||||
return
|
||||
with self.lock:
|
||||
self.output += chunk
|
||||
|
||||
def mark(self):
|
||||
with self.lock:
|
||||
return len(self.output)
|
||||
|
||||
def wait_for(self, needle, since=0, seconds=15.0):
|
||||
import time
|
||||
end = time.time() + seconds
|
||||
target = needle.encode()
|
||||
while time.time() < end:
|
||||
with self.lock:
|
||||
if target in self.output[since:]:
|
||||
return True
|
||||
time.sleep(0.05)
|
||||
return False
|
||||
|
||||
def type(self, data):
|
||||
os.write(self.fd, data if isinstance(data, bytes) else data.encode())
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
os.close(self.fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.kill(self.pid, 9)
|
||||
os.waitpid(self.pid, 0)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class TmuxServer:
|
||||
"""A private tmux server (own TMUX_TMPDIR) with one session running `cat`, so typed text is echoed back."""
|
||||
|
||||
|
||||
@@ -22,10 +22,12 @@ import glob
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(os.path.dirname(HERE))
|
||||
@@ -188,9 +190,117 @@ def main():
|
||||
pane_d.close()
|
||||
pane_e.close()
|
||||
|
||||
tmux_section(opts.probe, check)
|
||||
signal_section(opts.probe, check)
|
||||
|
||||
print("%d checks, %d failures" % (checks, len(failures)))
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
def signal_section(probe, check):
|
||||
"""Ctrl-C in the middle of a turn checkpoints it open; an uncaught exception reaches the debug log."""
|
||||
import signal
|
||||
home = tempfile.mkdtemp(prefix="detz-repl-sig-")
|
||||
env = {key: value for key, value in os.environ.items() if not key.startswith(("TAI_", "TMUX"))}
|
||||
env.update({"TAI_HOME": home, "TAI_PASSPHRASE": "", "NO_COLOR": "1", "HOME": home, "TAI_SESSION": "sigctx"})
|
||||
proc = subprocess.Popen([probe], env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True)
|
||||
proc.stdin.write("SLEEP\n")
|
||||
proc.stdin.flush()
|
||||
turn = os.path.join(home, "profiles", "default.turn.sigctx.json")
|
||||
end = time.time() + 15
|
||||
while time.time() < end and not os.path.exists(turn) and proc.poll() is None:
|
||||
time.sleep(0.05)
|
||||
time.sleep(0.4)
|
||||
proc.send_signal(signal.SIGINT)
|
||||
time.sleep(0.6)
|
||||
proc.stdin.write("after\n/quit\n")
|
||||
proc.stdin.flush()
|
||||
out, _ = proc.communicate(timeout=30)
|
||||
check("SIGINT: REPL survived and exited cleanly", proc.returncode == 0, str(proc.returncode))
|
||||
check("SIGINT: interrupted is reported", "interrupted" in out, repr(out[-300:]))
|
||||
sess = os.path.join(home, "profiles", "default.session.sigctx.json")
|
||||
got = contents(sess) if os.path.exists(sess) else []
|
||||
check("SIGINT: interrupted turn kept, next turn appended", got[:1] == [("user", "SLEEP")] and got[-2:] == [("user", "after"), ("assistant", "echo: after")], str(got))
|
||||
check("SIGINT: later turn closed the turn file", os.path.exists(turn) and read_json(turn).get("open") is False)
|
||||
|
||||
home2 = tempfile.mkdtemp(prefix="detz-repl-thr-")
|
||||
env["TAI_HOME"] = home2
|
||||
done = subprocess.run([probe, "--throw"], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=30)
|
||||
log = os.path.join(home2, "debug.log")
|
||||
text = open(log, encoding="utf-8").read() if os.path.exists(log) else ""
|
||||
check("terminate hook: process aborts", done.returncode != 0)
|
||||
check("terminate hook: ERROR app.exception logged", "ERROR" in text and "app.exception" in text and "kaboom" in text, text[:200])
|
||||
|
||||
|
||||
def tmux_section(probe, check):
|
||||
"""The same scenario in real tmux panes (a private tmux server, TMUX_PANE set by tmux itself)."""
|
||||
if shutil.which("tmux") is None:
|
||||
print("SKIP tmux panes: tmux is not installed")
|
||||
return
|
||||
root = tempfile.mkdtemp(prefix="detz-repl-tmux-")
|
||||
home = os.path.join(root, "home")
|
||||
os.makedirs(home)
|
||||
env = {key: value for key, value in os.environ.items() if not key.startswith("TMUX")}
|
||||
env["TMUX_TMPDIR"] = root
|
||||
|
||||
def tmux(*args):
|
||||
return subprocess.run(["tmux", "-f", "/dev/null"] + list(args), env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
|
||||
|
||||
def screen(pane):
|
||||
return tmux("capture-pane", "-p", "-t", pane, "-S", "-200").stdout
|
||||
|
||||
def wait_for(pane, needle, seconds=20):
|
||||
end = time.time() + seconds
|
||||
while time.time() < end:
|
||||
if needle in screen(pane):
|
||||
return True
|
||||
time.sleep(0.1)
|
||||
return False
|
||||
|
||||
def type_line(pane, text):
|
||||
tmux("send-keys", "-t", pane, "-l", text)
|
||||
tmux("send-keys", "-t", pane, "Enter")
|
||||
|
||||
started = tmux("new-session", "-d", "-s", "t", "-x", "200", "-y", "50", "-e", "TAI_HOME=" + home, "-e", "TAI_PASSPHRASE=", "-e", "NO_COLOR=1", "-e", "HOME=" + home,
|
||||
"bash --norc --noprofile")
|
||||
check("tmux session started", started.returncode == 0, started.stderr)
|
||||
if started.returncode != 0:
|
||||
return
|
||||
try:
|
||||
pane_a = tmux("list-panes", "-t", "t", "-F", "#{pane_id}").stdout.strip()
|
||||
# first start, quit, restart in the same pane
|
||||
type_line(pane_a, "%s; echo __DONE_$((0+1))__" % shlex.quote(probe))
|
||||
check("tmux A1 prompt", wait_for(pane_a, "tai[default]"))
|
||||
type_line(pane_a, "hello tmux")
|
||||
type_line(pane_a, "/quit")
|
||||
check("tmux A1 exit", wait_for(pane_a, "__DONE_1__"))
|
||||
files = session_files(home)
|
||||
check("tmux A1 session file", len(files) == 1, str(files))
|
||||
type_line(pane_a, "clear; %s; echo __DONE_$((0+2))__" % shlex.quote(probe))
|
||||
check("tmux A2 prompt", wait_for(pane_a, "tai[default]"))
|
||||
type_line(pane_a, "back again")
|
||||
type_line(pane_a, "/quit")
|
||||
check("tmux A2 exit", wait_for(pane_a, "__DONE_2__"))
|
||||
files = session_files(home)
|
||||
check("tmux A2 same file after restart", len(files) == 1, str(files))
|
||||
check("tmux A2 history resumed", files and contents(files[0]) == [("user", "hello tmux"), ("assistant", "echo: hello tmux"), ("user", "back again"), ("assistant", "echo: back again")],
|
||||
str(contents(files[0])) if files else "")
|
||||
# a second pane of the same window is another shell
|
||||
split = tmux("split-window", "-d", "-P", "-F", "#{pane_id}", "-t", "t", "bash --norc --noprofile")
|
||||
pane_b = split.stdout.strip()
|
||||
check("tmux second pane", split.returncode == 0 and pane_b and pane_b != pane_a, split.stderr)
|
||||
type_line(pane_b, "%s; echo __DONE_$((0+3))__" % shlex.quote(probe))
|
||||
check("tmux B prompt", wait_for(pane_b, "tai[default]"))
|
||||
type_line(pane_b, "second pane")
|
||||
type_line(pane_b, "/quit")
|
||||
check("tmux B exit", wait_for(pane_b, "__DONE_3__"))
|
||||
files = session_files(home)
|
||||
check("tmux B has its own session", len(files) == 2, str(files))
|
||||
mine = sorted(contents(path) for path in files)
|
||||
check("tmux B history independent", [("user", "second pane"), ("assistant", "echo: second pane")] in mine and len(mine) == 2)
|
||||
finally:
|
||||
tmux("kill-server")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include "config/config.h"
|
||||
#include <stdexcept>
|
||||
#include "repl/boot.h"
|
||||
#include "repl/interrupt.h"
|
||||
#include "repl/repl.h"
|
||||
#include "session/conversation.h"
|
||||
#include "session/shell_context.h"
|
||||
@@ -30,6 +32,13 @@ class EchoHost : public repl::ReplHost {
|
||||
std::fflush(stdout);
|
||||
::_exit(9);
|
||||
}
|
||||
if (text == "SLEEP") { // a long turn that honours Ctrl-C like the model loop must
|
||||
conv_.checkpoint();
|
||||
for (int i = 0; i < 100; i++) {
|
||||
if (repl::Interrupt::requested()) throw repl::TurnInterrupted();
|
||||
::usleep(50000);
|
||||
}
|
||||
}
|
||||
std::string reply = "echo: " + text;
|
||||
conv_.add_message("assistant", reply);
|
||||
conv_.close_turn();
|
||||
@@ -48,6 +57,10 @@ int main(int argc, char** argv) {
|
||||
if (a == "--profile" && i + 1 < argc) profile = argv[++i];
|
||||
else if (a == "--continue") resume = true;
|
||||
else if (a == "--yolo") yolo = true;
|
||||
else if (a == "--throw") { // uncaught exception with the debug log on: the terminate hook must log it
|
||||
repl::Boot::configure_debug(std::string("file"), std::nullopt);
|
||||
throw std::runtime_error("kaboom");
|
||||
}
|
||||
else if (a == "--oneshot" && i + 1 < argc) { oneshot = argv[++i]; has_oneshot = true; }
|
||||
}
|
||||
repl::BootOptions boot;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""TmuxPlexerController.ask with a real tmux client attached through a pty.
|
||||
|
||||
For every question kind both implementations (tai.py and the C++ controller)
|
||||
run against a private tmux server whose client is driven like a user: wait for
|
||||
the menu or popup title to be drawn, type the answer, read the reply. The
|
||||
replies must equal the expected value and each other.
|
||||
|
||||
Usage: python3 tests/tmux_ask.py [--probe PATH]
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import foundation_fixtures as fixtures # noqa: E402
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(os.path.dirname(HERE))
|
||||
PY_RUNNER = os.path.join(HERE, "foundation_parity.py")
|
||||
|
||||
CASES = [
|
||||
# label, screen, timeout, keys typed once the title is drawn, expected reply
|
||||
("menu option 2", {"title": "askmenu", "text": "pick", "kind": "choice", "options": ["first", "second", "third"]}, 10, "2", {"status": "ok", "value": 1}),
|
||||
("menu option 1", {"title": "askmenu", "text": "pick", "kind": "choice", "options": ["first", "second"]}, 10, "1", {"status": "ok", "value": 0}),
|
||||
("menu escape", {"title": "askmenu", "text": "pick", "kind": "choice", "options": ["first", "second"]}, 10, "\x1b", {"status": "cancelled"}),
|
||||
("text answer", {"title": "asktext", "text": "your name?", "kind": "text", "initial": ""}, 10, "hello popup\r", {"status": "ok", "value": "hello popup"}),
|
||||
("text unicode", {"title": "asktext", "text": "name?", "kind": "text", "initial": ""}, 10, "héllo über\r", {"status": "ok", "value": "héllo über"}),
|
||||
("message enter", {"title": "askmsg", "text": "read this", "kind": "message"}, 10, "\r", {"status": "ok", "value": True}),
|
||||
("popup ctrl-c", {"title": "asktext", "text": "name?", "kind": "text", "initial": ""}, 10, "\x03", {"status": "cancelled"}),
|
||||
("popup escape is typed into the popup, so it times out", {"title": "asktext", "text": "name?", "kind": "text", "initial": ""}, 2, "\x1b", {"status": "timeout"}),
|
||||
("popup timeout", {"title": "asktext", "text": "name?", "kind": "text", "initial": ""}, 2, None, {"status": "timeout"}),
|
||||
]
|
||||
|
||||
|
||||
def run_case(impl, probe, screen, timeout, keys, work):
|
||||
server = fixtures.TmuxServer(os.path.join(work, "root-%s-%d" % (impl, int(time.time() * 1000))))
|
||||
client = fixtures.AttachedClient(server)
|
||||
try:
|
||||
time.sleep(0.5)
|
||||
scenario = os.path.join(server.tmpdir, "scenario.json")
|
||||
with open(scenario, "w", encoding="utf-8") as handle:
|
||||
json.dump([["tmux_ctl", ["%0", "ask", [screen, timeout]]]], handle)
|
||||
home = os.path.join(server.tmpdir, "home")
|
||||
os.makedirs(home)
|
||||
env = {key: value for key, value in os.environ.items() if not key.startswith(("TAI_", "TMUX"))}
|
||||
env.update({"TAI_HOME": home, "TAI_PASSPHRASE": "", "HOME": home, "TMUX_TMPDIR": server.tmpdir})
|
||||
cmd = [sys.executable, "-I", PY_RUNNER, "--py-runner", home, scenario] if impl == "python" else [probe, scenario]
|
||||
mark = client.mark()
|
||||
proc = subprocess.Popen(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
|
||||
drawn = client.wait_for(screen["title"], since=mark)
|
||||
if keys is not None and drawn:
|
||||
time.sleep(0.3)
|
||||
client.type(keys)
|
||||
out, err = proc.communicate(timeout=60)
|
||||
if proc.returncode != 0:
|
||||
return {"error": err[-500:]}
|
||||
if keys is not None and not drawn:
|
||||
return {"error": "title never drawn"}
|
||||
return json.loads(out)[0]
|
||||
finally:
|
||||
client.close()
|
||||
server.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--probe", default=os.path.join(os.path.dirname(HERE), "build", "foundation_probe"))
|
||||
opts = parser.parse_args()
|
||||
if shutil.which("tmux") is None:
|
||||
print("SKIP: tmux is not installed")
|
||||
return 0
|
||||
work = tempfile.mkdtemp(prefix="detz-tmuxask-")
|
||||
failures = 0
|
||||
for label, screen, timeout, keys, expected in CASES:
|
||||
got = {impl: run_case(impl, opts.probe, screen, timeout, keys, work) for impl in ("python", "cpp")}
|
||||
for impl, reply in got.items():
|
||||
if reply != expected:
|
||||
failures += 1
|
||||
print("FAIL %s [%s]: expected %r, got %r" % (label, impl, expected, reply))
|
||||
print("%d cases x 2 implementations, %d failures" % (len(CASES), failures))
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user