Full port of the Store (17 tables, FTS5, per-profile slices, sessions, turn state, bots, settings, MCP registry), DebugLog, SETTINGS_CATALOG and boot_settings, ShellSession (as ShellContext), Conversation persistence, Repl/Boot, and the PlexerController family with FlowValidator/PlexerFlow. Verified by replaying 643 operations through tai.py and the C++ code (results, tables, FTS, schema, files identical) and by restarting the REPL under per-pane shells and checking the vault. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
247 lines
11 KiB
C++
247 lines
11 KiB
C++
// Unit tests for the foundation area that need no python oracle: hash
|
|
// vectors, UTF-8 helpers, the seal-enabled store paths (with a stand-in
|
|
// SealPort), boot settings, directory defaults, plexer detection, line input.
|
|
// The behaviour parity with tai.py lives in tests/foundation_parity.py.
|
|
#include <unistd.h>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <regex>
|
|
#include <sstream>
|
|
#include "config/boot_settings.h"
|
|
#include "config/config.h"
|
|
#include "plexer/plexer.h"
|
|
#include "repl/interrupt.h"
|
|
#include "repl/line_input.h"
|
|
#include "repl/repl.h"
|
|
#include "session/conversation.h"
|
|
#include "session/dir_defaults.h"
|
|
#include "session/md5.h"
|
|
#include "session/shell_context.h"
|
|
#include "store/clock.h"
|
|
#include "store/json.h"
|
|
#include "store/sha256.h"
|
|
#include "store/store.h"
|
|
#include "store/utf8.h"
|
|
#include "store/vault_files.h"
|
|
using namespace detz;
|
|
namespace fs = std::filesystem;
|
|
|
|
static int g_checks = 0, g_fail = 0;
|
|
#define CHECK(cond) do { g_checks++; if (!(cond)) { g_fail++; std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); } } while (0)
|
|
|
|
// A reversible stand-in for the vault crypto: enough to exercise every sealed code path of Store.
|
|
class FakeSeal : public store::SealPort {
|
|
public:
|
|
bool enabled() const override { return true; }
|
|
std::string lock(const std::string& t) const override {
|
|
if (is_sealed(t)) return t;
|
|
std::string out = kPrefix;
|
|
for (unsigned char c : t) { char b[4]; std::snprintf(b, sizeof b, "%02x", static_cast<unsigned>(c ^ 0x5a)); out += b; }
|
|
return out;
|
|
}
|
|
std::string unlock(const std::string& t) const override {
|
|
if (!is_sealed(t)) throw store::SealError("not sealed");
|
|
std::string out;
|
|
for (size_t i = 5; i + 1 < t.size(); i += 2) out += static_cast<char>(std::stoi(t.substr(i, 2), nullptr, 16) ^ 0x5a);
|
|
return out;
|
|
}
|
|
};
|
|
|
|
static Config temp_config(const std::string& profile = "default") {
|
|
fs::path home = fs::temp_directory_path() / ("detz-ft-" + store::Clock::uuid_hex().substr(0, 8));
|
|
fs::create_directories(home / "profiles");
|
|
Config c = Config::FromEnv();
|
|
c.home = home.string(); c.profilesDir = (home / "profiles").string(); c.audioDir = (home / "audio").string();
|
|
c.dbPath = (home / "memory.db").string(); c.historyPath = (home / "history").string(); c.profile = profile;
|
|
return c;
|
|
}
|
|
|
|
static void test_hashes() {
|
|
CHECK(store::Sha256::hex("") == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
|
|
CHECK(store::Sha256::hex("abc") == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
|
CHECK(store::Sha256::hex(std::string(1000, 'a')) == "41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3");
|
|
CHECK(session::Md5::hex("") == "d41d8cd98f00b204e9800998ecf8427e");
|
|
CHECK(session::Md5::hex("abc") == "900150983cd24fb0d6963f7d28e17f72");
|
|
CHECK(session::Md5::hex("1700000000.0").size() == 32);
|
|
}
|
|
|
|
static void test_utf8_and_json() {
|
|
using store::Utf8;
|
|
CHECK(Utf8::length("h\xc3\xa9llo \xf0\x9f\x98\x80") == 7);
|
|
CHECK(Utf8::slice("a\xc3\xa9z", 1, 2) == "\xc3\xa9");
|
|
CHECK(Utf8::suffix("ab\xc3\xa9", 2) == "b\xc3\xa9");
|
|
CHECK(Utf8::strip("\xc2\xa0 x \xe2\x80\x83") == "x");
|
|
auto sp = Utf8::split_first(" cmd the rest ");
|
|
CHECK(sp.first == "cmd" && sp.second == "the rest ");
|
|
CHECK(Utf8::sanitize("a\xff" "b") == "a\xef\xbf\xbd" "b");
|
|
CHECK(store::Json::float_repr(0.1) == "0.1" && store::Json::float_repr(1e16) == "1e+16" && store::Json::float_repr(100.0) == "100.0");
|
|
store::Json j = store::Json::loads("{\"b\": [1, 2.5, \"\\u00e9\"], \"a\": null}");
|
|
CHECK(j.dumps() == "{\"b\": [1, 2.5, \"\\u00e9\"], \"a\": null}");
|
|
CHECK(j.dumps(true) == "{\"a\": null, \"b\": [1, 2.5, \"\\u00e9\"]}");
|
|
bool threw = false;
|
|
try { store::Json::loads("{\"a\":"); } catch (const store::JsonError&) { threw = true; }
|
|
CHECK(threw);
|
|
CHECK(std::regex_match(store::Clock::now_iso(), std::regex("\\d{4}-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\d\\+00:00")));
|
|
CHECK(std::regex_match(store::Clock::uuid_hex(), std::regex("[0-9a-f]{32}")));
|
|
}
|
|
|
|
static void test_sealed_store() {
|
|
Config cfg = temp_config();
|
|
store::NullSeal plain;
|
|
{
|
|
store::Store st(cfg, plain);
|
|
st.log_event("default", "user", "say", "plain fox event");
|
|
st.save_secret("k", "secret-value-1");
|
|
st.save_session("default", {store::Json::loads("{\"role\": \"user\", \"content\": \"hi\"}")}, "main", "ctx");
|
|
st.save_system("default", "sys text");
|
|
st.history_add("default", "main", "typed line");
|
|
st.close();
|
|
}
|
|
FakeSeal seal;
|
|
{
|
|
store::Store st(cfg, seal); // upgrades the plaintext vault in place
|
|
auto raw = st.db().execute("SELECT text FROM events").rows;
|
|
CHECK(!raw.empty() && store::SealPort::is_sealed(raw[0][0].text()));
|
|
CHECK(store::SealPort::is_sealed(st.db().execute("SELECT value FROM secrets").rows[0][0].text()));
|
|
CHECK(st.load_secret("k") == std::optional<std::string>("secret-value-1"));
|
|
CHECK(st.history_load("default") == std::vector<std::string>{"typed line"});
|
|
CHECK(st.load_system("default") == std::optional<std::string>("sys text"));
|
|
auto msgs = st.load_session("default", "main", "ctx");
|
|
CHECK(msgs.size() == 1 && msgs[0].get_string("content") == "hi");
|
|
{ // seal_files() only upgrades *.sys.md, *.session.json and *.bots.json, so a ctx-scoped file stays plain until it is saved again (as in tai.py)
|
|
std::ifstream f(st.session_path("default", "ctx"));
|
|
std::stringstream ss; ss << f.rdbuf();
|
|
CHECK(!store::SealPort::is_sealed(ss.str()));
|
|
}
|
|
st.save_session("default", msgs, "main", "ctx");
|
|
{
|
|
std::ifstream f(st.session_path("default", "ctx"));
|
|
std::stringstream ss; ss << f.rdbuf();
|
|
CHECK(store::SealPort::is_sealed(ss.str()));
|
|
CHECK(st.load_session("default", "main", "ctx").size() == 1);
|
|
}
|
|
{
|
|
std::ifstream f(st.profile_path("default"));
|
|
std::stringstream ss; ss << f.rdbuf();
|
|
CHECK(store::SealPort::is_sealed(ss.str())); // default.sys.md was upgraded in place
|
|
}
|
|
auto hits = st.search_events("default", "fox");
|
|
CHECK(hits.size() == 1 && hits[0].text == "plain fox event"); // through the in-memory index
|
|
st.log_event("default", "user", "say", "second fox event");
|
|
CHECK(st.search_events("default", "fox").size() == 2);
|
|
CHECK(st.fts_search("fox", {"event"}).size() == 2);
|
|
st.close();
|
|
}
|
|
{
|
|
bool refused = false;
|
|
try { store::Store st(cfg, plain); } catch (const store::SealError& e) { refused = std::string(e.what()).find("sealed") != std::string::npos; }
|
|
CHECK(refused); // a sealed vault without the seal is refused loudly
|
|
}
|
|
}
|
|
|
|
static void test_boot_settings_and_dir_defaults() {
|
|
::unsetenv("TAI_THEME"); ::unsetenv("TAI_VOICE"); ::unsetenv("TAI_MUSE_BIN"); ::unsetenv("MUSE_BIN");
|
|
Config cfg = temp_config();
|
|
store::NullSeal seal;
|
|
store::Store st(cfg, seal);
|
|
st.upsert_settings({{"voice", store::Json("vault-voice")}, {"theme", store::Json("nord")}, {"muse_bin", store::Json("/opt/muse")}});
|
|
auto applied = BootSettings::Boot(cfg, st);
|
|
CHECK(cfg.voice == "vault-voice");
|
|
bool has_voice = false, has_theme = false;
|
|
for (auto& a : applied) { has_voice |= a == "voice"; has_theme |= a == "theme"; }
|
|
CHECK(has_voice && has_theme);
|
|
CHECK(std::string(std::getenv("TAI_THEME")) == "nord" && std::string(std::getenv("MUSE_BIN")) == "/opt/muse");
|
|
// env wins: a second boot with the env already exported changes nothing
|
|
::setenv("TAI_VOICE", "env-voice", 1);
|
|
Config again = cfg;
|
|
again.voice = "x";
|
|
auto second = again.ApplyStoreSettings(st);
|
|
bool voice_again = false;
|
|
for (auto& a : second) voice_again |= a == "voice";
|
|
CHECK(!voice_again && again.voice == "x");
|
|
::unsetenv("TAI_VOICE"); ::unsetenv("TAI_THEME"); ::unsetenv("MUSE_BIN");
|
|
|
|
session::Conversation conv(st, cfg, true, false, false, "ctxdd");
|
|
st.save_bots("default", store::Json::loads("{\"helper\": {\"nicknames\": []}}"));
|
|
st.save_dir_defaults(session::ShellContext::dir_key(), "prov", "mod", "helper");
|
|
std::string pinned_provider, pinned_model;
|
|
session::DirDefaults::Hooks hooks;
|
|
hooks.offered_providers = [] { return std::set<std::string>{"prov"}; };
|
|
hooks.pin_provider = [&](const std::string& p) { pinned_provider = p; };
|
|
hooks.pin_model = [&](const std::string& m) { pinned_model = m; };
|
|
std::string line = session::DirDefaults::apply(conv, hooks);
|
|
CHECK(pinned_provider == "prov" && pinned_model == "mod" && conv.bot() == "helper");
|
|
CHECK(line == "dir defaults for " + session::ShellContext::dir_key() + ": provider=prov model=mod bot=helper");
|
|
}
|
|
|
|
static void test_conversation_turns() {
|
|
Config cfg = temp_config();
|
|
store::NullSeal seal;
|
|
store::Store st(cfg, seal);
|
|
session::Conversation conv(st, cfg, true, false, false, "ctxturn");
|
|
CHECK(conv.messages().size() == 1 && conv.messages()[0].get_string("role") == "system");
|
|
conv.begin_turn("do the thing");
|
|
conv.add_message("user", "do the thing");
|
|
conv.checkpoint();
|
|
auto open = st.load_turn_state("default", "main", "ctxturn");
|
|
CHECK(open.find("open") && open.find("open")->as_bool() && open.get_string("goal") == "do the thing");
|
|
conv.add_message("assistant", "done");
|
|
conv.close_turn();
|
|
CHECK(!st.load_turn_state("default", "main", "ctxturn").find("open")->as_bool());
|
|
session::Conversation fresh(st, cfg, true, false, false, "ctxturn");
|
|
CHECK(fresh.messages().size() == 3); // system + the two restored messages
|
|
session::Conversation other(st, cfg, true, false, false, "ctxother");
|
|
CHECK(other.messages().size() == 1);
|
|
session::Conversation volatile_run(st, cfg, false, false, false, "ctxturn");
|
|
CHECK(volatile_run.messages().size() == 1); // persist=false never restores
|
|
CHECK(conv.switch_bot("nobody", true).rfind("unknown bot", 0) == 0);
|
|
}
|
|
|
|
static void test_plexer_detection() {
|
|
::unsetenv("TAI_PLEXER_SOCKET"); ::unsetenv("TMUX_PANE"); ::unsetenv("TAI_PLEXER_PANE");
|
|
CHECK(plexer::PlexerControllerFactory::detect() == nullptr);
|
|
::setenv("TMUX_PANE", "%4", 1);
|
|
auto t = plexer::PlexerControllerFactory::detect();
|
|
CHECK(t && t->kind() == "tmux");
|
|
::setenv("TAI_PLEXER_SOCKET", "/tmp/nonexistent.sock", 1);
|
|
::setenv("TAI_PLEXER_PANE", "9", 1);
|
|
auto n = plexer::PlexerControllerFactory::detect();
|
|
CHECK(n && n->kind() == "native" && dynamic_cast<plexer::NativePlexerController*>(n.get())->pane() == "9");
|
|
::unsetenv("TAI_PLEXER_SOCKET"); ::unsetenv("TMUX_PANE"); ::unsetenv("TAI_PLEXER_PANE");
|
|
CHECK(plexer::Plexer::shlex_quote("a b") == "'a b'" && plexer::Plexer::shlex_quote("") == "''" && plexer::Plexer::shlex_quote("it's") == "'it'\"'\"'s'");
|
|
}
|
|
|
|
static void test_line_input() {
|
|
int fds[2];
|
|
CHECK(::pipe(fds) == 0);
|
|
const char data[] = "first\r\nsecond\nlast-no-newline";
|
|
CHECK(::write(fds[1], data, sizeof data - 1) > 0);
|
|
::close(fds[1]);
|
|
repl::LineInput in(fds[0]);
|
|
std::string line;
|
|
CHECK(in.read("", line) == repl::LineInput::Status::Line && line == "first");
|
|
CHECK(in.read("", line) == repl::LineInput::Status::Line && line == "second");
|
|
CHECK(in.read("", line) == repl::LineInput::Status::Line && line == "last-no-newline");
|
|
CHECK(in.read("", line) == repl::LineInput::Status::Eof);
|
|
repl::Interrupt::raise();
|
|
int fds2[2];
|
|
CHECK(::pipe(fds2) == 0);
|
|
repl::LineInput quiet(fds2[0]);
|
|
CHECK(quiet.read("", line) == repl::LineInput::Status::Interrupted && !repl::Interrupt::requested());
|
|
::close(fds[0]); ::close(fds2[0]); ::close(fds2[1]);
|
|
}
|
|
|
|
int main() {
|
|
test_hashes();
|
|
test_utf8_and_json();
|
|
test_sealed_store();
|
|
test_boot_settings_and_dir_defaults();
|
|
test_conversation_turns();
|
|
test_plexer_detection();
|
|
test_line_input();
|
|
std::printf("%d checks, %d failures\n", g_checks, g_fail);
|
|
return g_fail ? 1 : 0;
|
|
}
|