agent-block 0.15.0

Lua-first Agent Runtime built on AgentMesh
-- compile_loop_cache_lifecycle.lua — mlua-lspec unit tests for ST2 cache lifecycle.
--
-- Run via:
--   mcp__lua-debugger__test_launch(
--     code_file = "tests/fixtures/compile_loop_cache_lifecycle.lua",
--     search_paths = ["blocks"]
--   )
--
-- Tests cover AC #12 (subtask-2.md):
--   #1 cache hit (mtime match + auto TTL): distill call_count unchanged
--   #2 cache miss (new path): distill called + cache written
--   #3 refresh="always": cache not used, distill always called
--   #4 refresh="files": mtime mismatch → distill called; mtime match → cache hit
--   #5 refresh="manual": mtime change → still uses cache
--   #6 per-iter rebuild path: mf_state.file_digest unchanged (crux-card §1)
--   #7 read_file_range verbatim: size > THRESHOLD still returns verbatim (crux-card §3)
--   #8 read_file_range line range guard: exceeds max → {ok=false}
--
-- NOTE: distill_subloop is a stub in ST2.  Tests use M._test_set_distill_subloop
-- to inject a counting spy.  M._test_helpers() exposes internal helpers directly.

local describe, it, expect = lust.describe, lust.it, lust.expect

local compile_loop = require("compile_loop")

-- ─────────────────────────────────────────────────────────────────────────────
-- Helpers
-- ─────────────────────────────────────────────────────────────────────────────

local h = compile_loop._test_helpers()

-- Build a minimal mf_state for tests (wraps M._test_make_mf_state).
local function make_state(refresh_mode)
    local s = compile_loop._test_make_mf_state()
    if refresh_mode then
        s.file_digest_refresh = refresh_mode
    end
    return s
end

-- Build a fake cached entry that looks like a real cache entry.
local function fake_cache(mtime_val)
    return {
        digest    = "digest content",
        line_index = "L1-10: section",
        mtime     = mtime_val,
        cached_at = os.time(),
    }
end

-- Write a temp file with given content and return its absolute path.
-- Uses os.tmpname() for a unique path; writes via io.open.
local function write_temp(content)
    local path = os.tmpname()
    local f = io.open(path, "w")
    if not f then error("cannot create temp file: " .. path) end
    f:write(content)
    f:close()
    return path
end

-- ─────────────────────────────────────────────────────────────────────────────
-- Test suite
-- ─────────────────────────────────────────────────────────────────────────────

describe("compile_loop ST2 cache lifecycle", function()

    -- ── AC #12.1: cache hit stops distill from being called ──────────────────

    it("cache hit (auto, mtime match, within TTL): distill not called", function()
        local call_count = 0
        compile_loop._test_set_distill_subloop(function(path, content, mf_state, conf) -- luacheck: ignore
            call_count = call_count + 1
            return "digest", "L1-1: stub", nil
        end)

        local state     = make_state("auto")
        local fake_mtime = 12345
        state.file_digest["/tmp/fake.lua"] = fake_cache(fake_mtime)
        -- Patch file_mtime via read_file_tool_handler path:
        -- We test should_use_cache directly with the same mtime to verify "auto" hit.
        local cached = state.file_digest["/tmp/fake.lua"]
        local result = h.should_use_cache(cached, fake_mtime, "auto")
        expect(result).to.equal(true)
        expect(call_count).to.equal(0)

        compile_loop._test_reset_distill_subloop()
    end)

    -- ── AC #12.2: cache miss → distill called + cache written ────────────────

    it("cache miss (new path): distill called and cache written", function()
        local call_count = 0
        compile_loop._test_set_distill_subloop(function(path, content, mf_state, conf) -- luacheck: ignore
            call_count = call_count + 1
            return "digest-for-" .. path, "L1-5: line index", nil
        end)

        -- Build a content string larger than the threshold (10000 chars).
        local big_content = string.rep("x", 10001)
        local path = write_temp(big_content)
        local state = make_state("auto")
        local target_set = { [path] = true }

        expect(state.file_digest[path]).to.equal(nil)

        local result = h.read_file_tool_handler(path, target_set, state, {})
        expect(result.ok).to.equal(true)
        expect(call_count).to.equal(1)
        expect(state.file_digest[path]).to_not.equal(nil)
        expect(state.file_digest[path].digest).to.equal("digest-for-" .. path)

        compile_loop._test_reset_distill_subloop()
        os.remove(path)
    end)

    -- ── AC #12.3: refresh="always" → cache never used ────────────────────────

    it("refresh=always: cache not used even when mtime matches", function()
        local fake_mtime = 99999
        local cached = fake_cache(fake_mtime)
        local result = h.should_use_cache(cached, fake_mtime, "always")
        expect(result).to.equal(false)
    end)

    -- ── AC #12.4: refresh="files" → mtime controls cache use ─────────────────

    it("refresh=files: mtime match → cache hit; mtime mismatch → cache miss", function()
        local fake_mtime = 55555
        local cached = fake_cache(fake_mtime)

        local hit  = h.should_use_cache(cached, fake_mtime, "files")
        local miss = h.should_use_cache(cached, fake_mtime + 1, "files")

        expect(hit).to.equal(true)
        expect(miss).to.equal(false)
    end)

    -- ── AC #12.5: refresh="manual" → always uses cache (mtime ignored) ────────

    it("refresh=manual: cache used regardless of mtime change", function()
        local cached = fake_cache(11111)
        local result = h.should_use_cache(cached, 99999, "manual")
        expect(result).to.equal(true)
    end)

    -- ── AC #12.6: per-iter rebuild path does NOT mutate mf_state.file_digest ──
    -- (crux-card §1 must_not_simplify: per-iter file cache survives reset)
    --
    -- The per-iter rebuild path in run_loop only updates:
    --   mf_state.iter, mf_state.last_err, mf_state.sr_digest_prev, mf_state.sr_history
    -- mf_state.file_digest must survive across iteration boundaries unchanged.
    -- We test by simulating: write a cache entry, call make_state (which mirrors the
    -- per-iter code path — only the listed fields are reset, file_digest is not touched).

    it("per-iter rebuild: mf_state.file_digest is read-only (not cleared)", function()
        local state = make_state("auto")

        -- Simulate a distill write (as would happen in a prior iter's read_file call).
        state.file_digest["/a/b.lua"] = {
            digest    = "pre-existing digest",
            line_index = "L1-20: functions",
            mtime     = 42,
            cached_at = os.time(),
        }

        -- Simulate the per-iter rebuild: only the fields listed in
        -- init.lua L1209-1231 are mutated (iter, last_err, sr_digest_prev).
        -- file_digest MUST NOT be touched.
        state.iter          = state.iter + 1
        state.last_err      = nil
        state.sr_digest_prev = nil
        -- sr_history append is done via update_state in the real loop but we
        -- only care that file_digest is untouched.

        expect(state.file_digest["/a/b.lua"]).to_not.equal(nil)
        expect(state.file_digest["/a/b.lua"].digest).to.equal("pre-existing digest")
    end)

    -- ── AC #12.7: read_file_range verbatim (crux-card §3) ────────────────────
    -- Even when file size > THRESHOLD, read_file_range returns verbatim lines.
    -- It must NOT pass through distillation.

    it("read_file_range: size>THRESHOLD file returns verbatim lines (no distill)", function()
        -- Build a file larger than the threshold.
        local lines = {}
        for i = 1, 300 do
            lines[i] = "line " .. i .. ": " .. string.rep("a", 40)
        end
        local big_content = table.concat(lines, "\n")
        -- Verify it exceeds threshold
        expect(#big_content > 10000).to.equal(true)

        local path = write_temp(big_content)
        local target_set = { [path] = true }

        local call_count = 0
        compile_loop._test_set_distill_subloop(function(...)  -- luacheck: ignore
            call_count = call_count + 1
            return "should not be called", "L1-?: stub", nil
        end)

        local result = h.read_file_range_tool_handler(path, 5, 10, target_set)
        expect(result.ok).to.equal(true)
        -- Verbatim content: line 5 through line 10
        expect(result.content).to.equal(table.concat({ lines[5], lines[6], lines[7], lines[8], lines[9], lines[10] }, "\n"))
        -- Distill was never called
        expect(call_count).to.equal(0)

        compile_loop._test_reset_distill_subloop()
        os.remove(path)
    end)

    -- ── AC #12.8: read_file_range line range guard ────────────────────────────

    it("read_file_range: range exceeding max returns {ok=false}", function()
        -- read_file_range_tool_handler validates without reading the file
        -- so path just needs to be in the allowlist.
        local fake_path = "/some/path.lua"
        local target_set = { [fake_path] = true }

        -- READ_FILE_RANGE_MAX_LINES = 500, so 501 lines should fail.
        local result = h.read_file_range_tool_handler(fake_path, 1, 501, target_set)
        expect(result.ok).to.equal(false)
        expect(type(result.error)).to.equal("string")
    end)

    it("read_file_range: invalid range (line_start > line_end) returns {ok=false}", function()
        local fake_path = "/some/path.lua"
        local target_set = { [fake_path] = true }

        local result = h.read_file_range_tool_handler(fake_path, 10, 5, target_set)
        expect(result.ok).to.equal(false)
    end)

    it("read_file_range: path not in allowlist returns {ok=false}", function()
        local result = h.read_file_range_tool_handler("/not/allowed.lua", 1, 5, {})
        expect(result.ok).to.equal(false)
        expect(result.error:find("not in target_files allowlist")).to_not.equal(nil)
    end)

end)