summaryrefslogtreecommitdiff
path: root/_selfcheck.lua
diff options
context:
space:
mode:
Diffstat (limited to '_selfcheck.lua')
-rw-r--r--_selfcheck.lua223
1 files changed, 223 insertions, 0 deletions
diff --git a/_selfcheck.lua b/_selfcheck.lua
new file mode 100644
index 0000000..fa6a0e2
--- /dev/null
+++ b/_selfcheck.lua
@@ -0,0 +1,223 @@
+-- Self-check for panto-warming. Run with:
+--
+-- panto lua _selfcheck.lua
+--
+-- Every module under test is pure Lua (no session, no panto module needed),
+-- so plain lua5.4 runs it too. The fidelity probe at the bottom uses the
+-- host's real JSON codec when the interpreter provides one.
+
+local here = (arg and arg[0] or "_selfcheck.lua"):match("^(.*)/") or "."
+package.path = here .. "/?.lua;" .. here .. "/?/init.lua;" .. package.path
+
+local config = require("warming.config")
+local policy = require("warming.policy")
+
+local SEC, MIN, HOUR = 1000, 60000, 3600000
+
+-- ---------------------------------------------------------------------------
+-- Duration strings
+-- ---------------------------------------------------------------------------
+
+assert(config.parse_duration("4 minutes") == 4 * MIN, "4 minutes")
+assert(config.parse_duration("30 seconds") == 30 * SEC, "30 seconds")
+assert(config.parse_duration("1 hour") == HOUR, "1 hour")
+assert(config.parse_duration("2h") == 2 * HOUR, "2h")
+assert(config.parse_duration(" 10 mins ") == 10 * MIN, "whitespace and synonyms")
+
+local function duration_fails(text, why)
+ local ms, err = config.parse_duration(text)
+ assert(ms == nil, "parse_duration accepted " .. why)
+ assert(type(err) == "string", "parse_duration explains " .. why)
+end
+duration_fails("soon", "a non-duration")
+duration_fails("4", "a bare number")
+duration_fails("0 minutes", "a zero duration")
+duration_fails("-1 minutes", "a negative duration")
+duration_fails("1 fortnight", "an unknown unit")
+duration_fails(4, "a non-string")
+
+-- ---------------------------------------------------------------------------
+-- Layer merging
+-- ---------------------------------------------------------------------------
+
+local s = config.defaults()
+assert(s.enabled == false, "warming is off unless configured")
+assert(s.interval_ms == 4 * MIN and s.duration_ms == 30 * MIN, "opencode defaults")
+assert(s.prompt:find("OK"), "default prompt asks for OK")
+
+assert(config.apply_layer(s, true, "x") == true, "scalar true applies")
+assert(s.enabled == true, "scalar true enables")
+assert(config.apply_layer(s, { interval = "2 minutes" }, "x") == true, "table applies")
+assert(s.interval_ms == 2 * MIN, "interval overridden")
+assert(s.duration_ms == 30 * MIN, "omitted keys keep earlier values")
+assert(config.apply_layer(s, false, "x") == true, "scalar false applies")
+assert(s.enabled == false, "scalar false disables")
+assert(config.apply_layer(s, { enabled = false, duration = "10 minutes" }, "x") == true, "enabled key applies")
+assert(s.enabled == false, "warming.enabled = false disables")
+assert(s.duration_ms == 10 * MIN, "duration still merged while disabled")
+assert(config.apply_layer(s, { prompt = "stay put" }, "x") == true, "prompt applies")
+assert(s.prompt == "stay put" and s.enabled == true, "table presence enables")
+
+local function layer_fails(value, why)
+ local ok, err = config.apply_layer(config.defaults(), value, "/x/config.toml")
+ assert(ok == nil, "apply_layer accepted " .. why)
+ assert(type(err) == "string" and err:find("/x/config.toml"), "error names the file for " .. why)
+end
+layer_fails("always", "a string scalar")
+layer_fails({ bogus = 1 }, "an unknown key")
+layer_fails({ prompt = "" }, "an empty prompt")
+layer_fails({ interval = "1 fortnight" }, "a bad duration")
+layer_fails({ enabled = "yes" }, "a non-boolean enabled")
+
+-- Layer walk over fake config layers (base < user < project < local).
+local docs = {
+ ["/base/config.toml"] = { warming = true },
+ ["/user/config.toml"] = { warming = { interval = "2 minutes" } },
+ ["/project/config.toml"] = { warming = { duration = "10 minutes" } },
+ ["/local/config.toml"] = { warming = false },
+}
+local fake_io = {
+ read_file = function(path)
+ return docs[path] and path or nil -- the "file contents" are its path
+ end,
+ parse = function(text)
+ return docs[text]
+ end,
+}
+local fake_ext = {
+ dirs = { layers = {
+ { name = "base", dir = "/base" },
+ { name = "user", dir = "/user" },
+ { name = "project", dir = "/project" },
+ { name = "local", dir = "/local" },
+ { name = "missing", dir = "/nowhere" },
+ } },
+}
+local merged = assert(config.read(fake_ext, fake_io))
+assert(merged.interval_ms == 2 * MIN and merged.duration_ms == 10 * MIN, "layers merge per key")
+assert(merged.enabled == false, "the highest layer's scalar wins")
+
+docs["/local/config.toml"] = { warming = { interval = "90 seconds" } }
+merged = assert(config.read(fake_ext, fake_io))
+assert(merged.enabled == true and merged.interval_ms == 90 * SEC, "later layers enable and override")
+
+docs["/local/config.toml"] = { warming = { typo = true } }
+local bad, err = config.read(fake_ext, fake_io)
+assert(bad == nil and err:find("/local/config.toml"), "a bad layer fails with its path")
+
+docs["/local/config.toml"] = { warming = true }
+fake_io.parse = function() error("boom") end
+bad, err = config.read(fake_ext, fake_io)
+assert(bad == nil and err:find("boom"), "a parse error surfaces its reason")
+
+-- ---------------------------------------------------------------------------
+-- Timing policy: the opencode timelines
+-- ---------------------------------------------------------------------------
+
+local function fire_times(p, from, to, step)
+ local fired = {}
+ local t = from
+ while t <= to do
+ if p:due(t) then
+ fired[#fired + 1] = t / MIN
+ p:attempted(t)
+ end
+ t = t + step
+ end
+ return fired
+end
+
+-- 00:00 real request; warm every 4 minutes; stop at 30 minutes.
+do
+ local p = policy.new(4 * MIN, 30 * MIN)
+ assert(p:expired(0), "no window before any activity")
+ p:activity(0)
+ assert(not p:due(0), "no warm request at the moment of activity")
+ local fired = fire_times(p, 15 * SEC, 40 * MIN, 15 * SEC)
+ assert(#fired == 7, "seven warm attempts in the default window, got " .. #fired)
+ for i, at in ipairs(fired) do
+ assert(at == i * 4, "warm attempt " .. i .. " at " .. at .. "m, want " .. (i * 4) .. "m")
+ end
+ assert(p:expired(30 * MIN), "window closes at 30m")
+ assert(not p:expired(29 * MIN), "window still open at 29m")
+end
+
+-- 00:00 real request; 00:20 real request restarts both timers; warms at
+-- 00:24; stops at 00:50.
+do
+ local p = policy.new(4 * MIN, 30 * MIN)
+ p:turn_started(0)
+ assert(not p:due(4 * MIN), "a turn in flight blocks warming")
+ p:turn_ended(20 * MIN)
+ assert(not p:due(20 * MIN), "interval restarts after the turn")
+ assert(not p:due(23 * MIN), "not due at 23m")
+ assert(p:due(24 * MIN), "due at 24m")
+ p:attempted(24 * MIN)
+ assert(not p:due(27 * MIN + 59 * SEC), "next attempt is a full interval later")
+ assert(p:due(28 * MIN), "due at 28m")
+ assert(not p:expired(49 * MIN), "window runs to 50m")
+ assert(p:expired(50 * MIN), "window closes at 50m")
+end
+
+-- An interval as long as the window: the window always expires first.
+do
+ local p = policy.new(30 * MIN, 30 * MIN)
+ p:activity(0)
+ local fired = fire_times(p, 0, 60 * MIN, 15 * SEC)
+ assert(#fired == 0, "interval >= duration means no warm request ever")
+end
+
+-- Entry eval contract: loading the extension chunk is side-effect-free and
+-- returns the one activation entry.
+local entry = assert(loadfile(here .. "/init.lua"))()
+assert(type(entry) == "table" and entry.name == "warming" and type(entry.activate) == "function",
+ "init.lua returns the warming entry")
+
+-- ---------------------------------------------------------------------------
+-- Tool-schema fidelity probe (report-only)
+--
+-- Warming copies the session's tool declarations to the warm request
+-- through the platform JSON codec: `agent:tools()` decodes the session's
+-- schema bytes and `set_tools` re-encodes them. If encode(decode(x)) is not
+-- byte-identical to x, the warm request's tool prefix diverges from the
+-- session's own requests and cannot write a cache entry they will hit (see
+-- the README). This probe measures how often that drift happens with the
+-- codec in this interpreter. It reports, but does not fail: the drift is a
+-- property of the codec, not of this rock.
+-- ---------------------------------------------------------------------------
+
+local ok_panto, panto = pcall(require, "panto")
+local json = ok_panto and type(panto) == "table" and panto.ext and panto.ext.json
+if json and type(json.encode) == "function" then
+ local schemas = {
+ { type = "object", properties = {}, additionalProperties = false },
+ { type = "object", properties = { query = { type = "string" } }, required = { "query" } },
+ { type = "object", title = "run", properties = {
+ command = { type = "string" }, cwd = { type = "string" },
+ timeout = { type = "integer", minimum = 1 },
+ env = { type = "array", items = { type = "string" } },
+ }, required = { "command" }, additionalProperties = false },
+ { type = "object", properties = {
+ name = { type = "string" }, description = { type = "string" },
+ schema = { type = "object", additionalProperties = true },
+ labels = { type = "array", items = { type = "string" } },
+ nested = { type = "object", properties = {
+ a = { type = "number" }, b = { type = "boolean" }, c = { type = "null" },
+ } },
+ } },
+ }
+ local drift = 0
+ for _, schema in ipairs(schemas) do
+ local encoded = json.encode(schema)
+ if json.encode(json.decode(encoded)) ~= encoded then
+ drift = drift + 1
+ end
+ end
+ print(string.format(
+ "probe: schema re-encode drift %d/%d (%s)",
+ drift, #schemas, drift == 0 and "byte-stable" or "tool prefix fidelity degraded; see README"))
+else
+ print("probe: skipped (no panto.ext.json in this interpreter)")
+end
+
+print("panto-warming selfcheck: OK")