diff --git a/taicpp/docs/foundation.md b/taicpp/docs/foundation.md index 5f7ad48..e87f440 100644 --- a/taicpp/docs/foundation.md +++ b/taicpp/docs/foundation.md @@ -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 diff --git a/taicpp/src/store/debuglog.cpp b/taicpp/src/store/debuglog.cpp index 29c76e1..23703a9 100644 --- a/taicpp/src/store/debuglog.cpp +++ b/taicpp/src/store/debuglog.cpp @@ -1,8 +1,10 @@ #include "debuglog.h" #include #include +#include #include #include +#include #include #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(); diff --git a/taicpp/tests/foundation.mk b/taicpp/tests/foundation.mk index 3010f1c..e1f6b40 100644 --- a/taicpp/tests/foundation.mk +++ b/taicpp/tests/foundation.mk @@ -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 diff --git a/taicpp/tests/foundation_fixtures.py b/taicpp/tests/foundation_fixtures.py index 3fcfc42..2d5d301 100644 --- a/taicpp/tests/foundation_fixtures.py +++ b/taicpp/tests/foundation_fixtures.py @@ -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.""" diff --git a/taicpp/tests/repl_persist.py b/taicpp/tests/repl_persist.py index 35b77d7..e070fc1 100644 --- a/taicpp/tests/repl_persist.py +++ b/taicpp/tests/repl_persist.py @@ -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()) diff --git a/taicpp/tests/repl_probe.cpp b/taicpp/tests/repl_probe.cpp index c732034..64b1770 100644 --- a/taicpp/tests/repl_probe.cpp +++ b/taicpp/tests/repl_probe.cpp @@ -12,7 +12,9 @@ #include #include #include "config/config.h" +#include #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; diff --git a/taicpp/tests/tmux_ask.py b/taicpp/tests/tmux_ask.py new file mode 100644 index 0000000..f023498 --- /dev/null +++ b/taicpp/tests/tmux_ask.py @@ -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())