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:
2026-10-25 05:03:39 +01:00
co-authored by Claude Sonnet 5.5
parent 4583265122
commit caba117750
7 changed files with 289 additions and 6 deletions
+3 -4
View File
@@ -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
+10 -2
View File
@@ -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();
+1
View File
@@ -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
+61
View File
@@ -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."""
+110
View File
@@ -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())
+13
View File
@@ -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;
+91
View File
@@ -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())