307 lines
15 KiB
Python
307 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""Conversation history survives a restart in the same pane.
|
|
|
|
Starts the real REPL binary (build/repl_probe: Repl + Conversation + Store, a
|
|
scripted host in place of the model) repeatedly, each time as a child of a
|
|
long-lived shell that stands for one terminal pane (TMUX_PANE and
|
|
TAI_PLEXER_PANE set, a throwaway TAI_HOME, no TAI_SESSION). Every assertion
|
|
reads vault state (profiles/*.session.*.json, *.turn.*.json, memory.db), never
|
|
the program's output.
|
|
|
|
tai.py keys a conversation by the nearest shell ancestor (ShellSession.current),
|
|
and a pane is exactly one such shell, so:
|
|
- same pane, restart -> same session file, history resumes
|
|
- different pane -> its own session file, history independent
|
|
- TAI_SESSION=<word> -> panes share one pinned context
|
|
- interrupted turn -> --continue picks it up
|
|
|
|
Usage: python3 tests/repl_persist.py [--probe PATH]
|
|
"""
|
|
import argparse
|
|
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))
|
|
|
|
|
|
class Pane:
|
|
"""One long-lived shell = one terminal pane."""
|
|
|
|
def __init__(self, home, probe, number, extra_env=None):
|
|
self.probe = probe
|
|
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,
|
|
"TMUX_PANE": "%%%d" % number, "TAI_PLEXER_PANE": str(number)})
|
|
env.update(extra_env or {})
|
|
self.shell = subprocess.Popen(["bash", "--norc", "--noprofile"], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT, env=env, universal_newlines=True)
|
|
|
|
def run(self, lines, *flags):
|
|
"""Run the probe in this pane with `lines` on stdin; returns its exit code."""
|
|
feed = "".join(line + "\n" for line in lines)
|
|
command = "printf %%s %s | %s %s; echo __RC=$?\n" % (shlex.quote(feed), shlex.quote(self.probe), " ".join(shlex.quote(f) for f in flags))
|
|
self.shell.stdin.write(command)
|
|
self.shell.stdin.flush()
|
|
for raw in self.shell.stdout:
|
|
if "__RC=" in raw: # the prompt has no newline, so the marker can follow it on the same line
|
|
return int(raw.strip().split("__RC=")[1])
|
|
raise RuntimeError("pane shell died")
|
|
|
|
def close(self):
|
|
try:
|
|
self.shell.stdin.close()
|
|
self.shell.wait(timeout=10)
|
|
except Exception:
|
|
self.shell.kill()
|
|
|
|
|
|
def session_files(home, profile="default"):
|
|
return sorted(glob.glob(os.path.join(home, "profiles", profile + ".session.*.json")))
|
|
|
|
|
|
def read_json(path):
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
return json.load(handle)
|
|
|
|
|
|
def contents(path):
|
|
return [(item["role"], item["content"]) for item in read_json(path)]
|
|
|
|
|
|
def history(home):
|
|
db = sqlite3.connect(os.path.join(home, "memory.db"))
|
|
try:
|
|
return [row[0] for row in db.execute("SELECT text FROM input_history WHERE profile = 'default' ORDER BY id").fetchall()]
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--probe", default=os.path.join(os.path.dirname(HERE), "build", "repl_probe"))
|
|
opts = parser.parse_args()
|
|
home = tempfile.mkdtemp(prefix="detz-repl-")
|
|
checks = 0
|
|
failures = []
|
|
|
|
def check(label, ok, detail=""):
|
|
nonlocal checks
|
|
checks += 1
|
|
if not ok:
|
|
failures.append("%s %s" % (label, detail))
|
|
print("FAIL %s %s" % (label, detail))
|
|
|
|
pane_a = Pane(home, opts.probe, 11)
|
|
pane_b = Pane(home, opts.probe, 12)
|
|
try:
|
|
# first start in pane A
|
|
check("A1 exit", pane_a.run(["hello one", "hello two", "/quit"]) == 0)
|
|
files = session_files(home)
|
|
check("A1 one session file", len(files) == 1, str(files))
|
|
sid_file = files[0]
|
|
check("A1 file is keyed by a shell id", ".session.p" not in sid_file and "-p" in os.path.basename(sid_file), sid_file)
|
|
expect = [("user", "hello one"), ("assistant", "echo: hello one"), ("user", "hello two"), ("assistant", "echo: hello two")]
|
|
check("A1 messages saved", contents(sid_file) == expect, str(contents(sid_file)))
|
|
check("A1 system message not stored", all(role != "system" for role, _ in contents(sid_file)))
|
|
|
|
# quit and restart in the same pane: the same file resumes
|
|
check("A2 exit", pane_a.run(["after restart", "/quit"]) == 0)
|
|
files = session_files(home)
|
|
check("A2 still one session file", files == [sid_file], str(files))
|
|
check("A2 history resumed and extended", contents(sid_file) == expect + [("user", "after restart"), ("assistant", "echo: after restart")], str(contents(sid_file)))
|
|
|
|
# another pane: independent conversation
|
|
check("B1 exit", pane_b.run(["other pane"]) == 0)
|
|
files = session_files(home)
|
|
check("B1 second session file", len(files) == 2, str(files))
|
|
other = [path for path in files if path != sid_file][0]
|
|
check("B1 own history only", contents(other) == [("user", "other pane"), ("assistant", "echo: other pane")], str(contents(other)))
|
|
check("B1 pane A untouched", len(contents(sid_file)) == 6)
|
|
|
|
# pane A again, one-shot path also resumes and saves
|
|
check("A3 oneshot exit", pane_a.run([], "--oneshot", "one shot q") == 0)
|
|
check("A3 oneshot appended", contents(sid_file)[-2:] == [("user", "one shot q"), ("assistant", "echo: one shot q")] and len(contents(sid_file)) == 8, str(contents(sid_file)))
|
|
check("A3 input history in the vault", history(home)[:3] == ["hello one", "hello two", "/quit"], str(history(home)))
|
|
|
|
# shell_sessions rows registered, keyed like the files
|
|
db = sqlite3.connect(os.path.join(home, "memory.db"))
|
|
sids = {row[0] for row in db.execute("SELECT sid FROM shell_sessions").fetchall()}
|
|
db.close()
|
|
sid_a = os.path.basename(sid_file)[len("default.session."):-len(".json")]
|
|
check("shell_sessions row for pane A", sid_a in sids, "%s not in %s" % (sid_a, sids))
|
|
|
|
# python reads what C++ wrote (same files, same keys)
|
|
sys.path.insert(0, ROOT)
|
|
env_backup = dict(os.environ)
|
|
os.environ["TAI_HOME"] = home
|
|
import argparse as ap
|
|
import tai
|
|
config = tai.Config(ap.Namespace(profile="default", yes=False, no_opencode=False))
|
|
store = tai.Store(config, tai.Seal(home, ""))
|
|
loaded = store.load_session("default", ctx=sid_a)
|
|
check("python loads the C++ session", [(m["role"], m["content"]) for m in loaded] == contents(sid_file))
|
|
# and C++ resumes a session python wrote
|
|
store.save_session("default", [{"role": "system", "content": "s"}, {"role": "user", "content": "from python"}, {"role": "assistant", "content": "ok"}], ctx=sid_a)
|
|
store.close()
|
|
os.environ.clear()
|
|
os.environ.update(env_backup)
|
|
check("A4 exit", pane_a.run(["and back"]) == 0)
|
|
check("A4 C++ resumed the python-written session", contents(sid_file) == [("user", "from python"), ("assistant", "ok"), ("user", "and back"), ("assistant", "echo: and back")], str(contents(sid_file)))
|
|
finally:
|
|
pane_a.close()
|
|
pane_b.close()
|
|
|
|
# interrupted turn and --continue, in a fresh pane
|
|
home2 = tempfile.mkdtemp(prefix="detz-repl-")
|
|
pane_c = Pane(home2, opts.probe, 21)
|
|
try:
|
|
check("C1 crash exit code", pane_c.run(["CRASH"]) == 9)
|
|
files = session_files(home2)
|
|
check("C1 checkpoint saved the user message", len(files) == 1 and contents(files[0]) == [("user", "CRASH")], str(files))
|
|
turns = glob.glob(os.path.join(home2, "profiles", "default.turn.*.json"))
|
|
check("C1 turn left open", len(turns) == 1 and read_json(turns[0]).get("open") is True and read_json(turns[0]).get("goal") == "CRASH", str(turns))
|
|
check("C2 continue exit", pane_c.run(["/quit"], "--continue") == 0)
|
|
got = contents(files[0])
|
|
check("C2 resume turn ran", got[0] == ("user", "CRASH") and got[1][0] == "user" and got[1][1].startswith("Continue where you left off: the interrupted request was: CRASH"), str(got))
|
|
check("C2 turn closed", read_json(turns[0]).get("open") is False)
|
|
finally:
|
|
pane_c.close()
|
|
|
|
# a pinned TAI_SESSION is shared by different panes
|
|
home3 = tempfile.mkdtemp(prefix="detz-repl-")
|
|
pane_d = Pane(home3, opts.probe, 31, {"TAI_SESSION": "pinned1"})
|
|
pane_e = Pane(home3, opts.probe, 32, {"TAI_SESSION": "pinned1"})
|
|
try:
|
|
check("D1 exit", pane_d.run(["from d"]) == 0)
|
|
check("D2 exit", pane_e.run(["from e"]) == 0)
|
|
files = session_files(home3)
|
|
check("D shared pinned context", [os.path.basename(f) for f in files] == ["default.session.pinned1.json"], str(files))
|
|
check("D both panes wrote it", contents(files[0]) == [("user", "from d"), ("assistant", "echo: from d"), ("user", "from e"), ("assistant", "echo: from e")], str(contents(files[0])))
|
|
finally:
|
|
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())
|