diff --git a/.codecompanion/chat.md b/.codecompanion/chat.md index b0f53c59b..425cb6f92 100644 --- a/.codecompanion/chat.md +++ b/.codecompanion/chat.md @@ -15,6 +15,7 @@ Messages in the chat buffer are lua table objects as seen. They contain the role _meta = { cycle = 1, id = 708950413, + estimated_tokens = 20, tag = "system_prompt_from_config", }, opts = { @@ -25,6 +26,7 @@ Messages in the chat buffer are lua table objects as seen. They contain the role }, { _meta = { cycle = 1, + estimated_tokens = 20, id = 533315931, sent = true }, @@ -32,8 +34,29 @@ Messages in the chat buffer are lua table objects as seen. They contain the role visible = true }, role = "user" - content = "Are you working?", - }, { + content = "Are you working? Sharing a file with you", + }, + { + _meta = { + cycle = 1, + estimated_tokens = 3556, + id = 1048633318, + index = 5, + sent = true, + source = "editor_context", + tag = "file" + }, + content = "An example file", + context = { + id = "some_path/some_file.lua", + path = "/Users/Oli/some_path/some_file.lua", + }, + opts = { + visible = false + }, + role = "user" + }, + { _meta = { cycle = 1, id = 1141409506, diff --git a/AGENTS.md b/AGENTS.md index 97eee7a78..d5b5f7a29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ This is a Neovim plugin written in Lua, which allows developers to code with LLM - **Naming:** snake_case for files/functions, PascalCase for classes, underscore prefix for private functions - **Explicit names:** `pattern` not `pat`, `should_include` not `include_ok` - **Readable code:** names, variables, and control flow should read like clean English. Avoid generic names like `ctx` — use domain-specific names (`permission`, `request`, `source`) +- **Plain language:** avoid jargon shortcuts in code, comments, commit messages, and chat. Don't say "no-op" — say what the code actually does ("returns unchanged", "does nothing", "skipped because already edited") - **Function params:** prefer a single table argument over positional args - **Error handling:** `pcall` + `log:error()`, return nil on failure - **Type annotations:** LuaCATS for public APIs. Keep doc blocks concise — one description line, params should be self-explanatory without inline comments diff --git a/lua/codecompanion/config.lua b/lua/codecompanion/config.lua index 4ce788836..389b29a7d 100644 --- a/lua/codecompanion/config.lua +++ b/lua/codecompanion/config.lua @@ -694,17 +694,24 @@ If you are providing code changes, use the insert_edit_into_file tool (if availa }, opts = { context_management = { - trigger = 0.75, -- Compaction starts at 75% of the context window limit - enabled = function(adapter) - if adapter.type ~= "http" then - return false - end - -- Anthropic and OpenAI have their own server-side compaction - if adapter.vendor and (adapter.vendor == "anthropic" or adapter.vendor == "openai") then - return false - end - return true - end, + ---@type boolean|fun(adapter: CodeCompanion.HTTPAdapter|CodeCompanion.ACPAdapter): boolean + enabled = true, + + editing = { + trigger = 0.65, -- 65% of the context window + exclude_tools = { "memory" }, -- tools whose result are never edited + keep_cycles = 3, -- preserve tool results from the last N cycles + }, + + compaction = { + trigger = 0.85, -- 85% of the context window + + ---The adapter to use for compaction. Defaults to the current chat adapter + ---@type nil|string|{ name: string, model:string } + adapter = nil, + + fallback_to_chat_adapter = false, -- on failure, retry with the chat adapter? + }, }, blank_prompt = "", -- The prompt to use when the user doesn't provide a prompt @@ -1354,6 +1361,24 @@ M.setup = function(args) M.config.interactions.chat.editor_context = nil end + -- TODO: Deprecate in v20.0.0 and remove in v21.0.0 + -- Legacy `context_management.trigger` migrates to `context_management.compaction.trigger` + local context_management = args.interactions + and args.interactions.chat + and args.interactions.chat.opts + and args.interactions.chat.opts.context_management + if context_management and context_management.trigger ~= nil then + vim.notify( + "[CodeCompanion] `context_management.trigger` is deprecated. Use `context_management.compaction.trigger` instead.", + vim.log.levels.WARN, + { title = "CodeCompanion" } + ) + if not (context_management.compaction and context_management.compaction.trigger ~= nil) then + M.config.interactions.chat.opts.context_management.compaction.trigger = context_management.trigger + end + M.config.interactions.chat.opts.context_management.trigger = nil + end + M.config.interactions.chat.keymaps = remove_disabled_keymaps(M.config.interactions.chat.keymaps) M.config.interactions.cli.keymaps = remove_disabled_keymaps(M.config.interactions.cli.keymaps) M.config.interactions.inline.keymaps = remove_disabled_keymaps(M.config.interactions.inline.keymaps) diff --git a/lua/codecompanion/interactions/chat/buffer_diffs.lua b/lua/codecompanion/interactions/chat/buffer_diffs.lua index 43cf14e08..01b149800 100644 --- a/lua/codecompanion/interactions/chat/buffer_diffs.lua +++ b/lua/codecompanion/interactions/chat/buffer_diffs.lua @@ -11,7 +11,7 @@ local diff = vim.text.diff or vim.diff ---@class CodeCompanion.BufferDiffs ---@field buffers table Map of buffer numbers to their states ----@field augroup integer The autocmd group ID +---@field augroup number The autocmd group ID ---@field sync fun(self: CodeCompanion.BufferDiffs, bufnr: number): nil Start syncing a buffer ---@field unsync fun(self: CodeCompanion.BufferDiffs, bufnr: number): nil Stop syncing a buffer ---@field get_changes fun(self: CodeCompanion.BufferDiffs, bufnr: number): boolean, table diff --git a/lua/codecompanion/interactions/chat/context_management/editing.lua b/lua/codecompanion/interactions/chat/context_management/editing.lua new file mode 100644 index 000000000..ffcb38b06 --- /dev/null +++ b/lua/codecompanion/interactions/chat/context_management/editing.lua @@ -0,0 +1,91 @@ +--============================================================================= +-- Context Editing +-- +-- Replaces aged messages in the chat buffer to reduce the token count. This +-- is done by mutating the message object, in-place. +-- +-- Currently, only tool results are edited. +-- +-- Sources: +-- https://platform.claude.com/docs/en/build-with-claude/context-editing +--============================================================================= + +local tokens = require("codecompanion.utils.tokens") + +local M = {} + +M.PLACEHOLDERS = { + tool_result = "Tool result cleared to save context. Re-run the tool if you need this output", +} + +---@class CodeCompanion.ContextManagement.Editing.Opts +---@field current_cycle integer The cycle the chat buffer is currently on +---@field exclude_tools? string[] Tool names whose results are never edited +---@field keep_cycles integer Preserve tool results from the most recent N cycles + +---Builds a map of tool call_id to tool name +---@param messages CodeCompanion.Chat.Messages +---@return table +local function map_tool_calls(messages) + local map = {} + for _, msg in ipairs(messages) do + if msg.tools and msg.tools.calls then + for _, call in ipairs(msg.tools.calls) do + local fn = call["function"] + if call.id and fn and fn.name then + map[call.id] = fn.name + end + end + end + end + return map +end + +---Edit tool result messages older than the keep_cycles window +---@param messages CodeCompanion.Chat.Messages +---@param opts CodeCompanion.ContextManagement.Editing.Opts +---@return number Number of messages cleared +local function tool_results(messages, opts) + local exclude = {} + for _, name in ipairs(opts.exclude_tools or {}) do + exclude[name] = true + end + + local tool_names = map_tool_calls(messages) + local cutoff = opts.current_cycle - opts.keep_cycles + local placeholder = M.PLACEHOLDERS.tool_result + local cleared = 0 + + for _, msg in ipairs(messages) do + local is_tool_result = msg.role == "tool" and msg.tools and msg.tools.call_id + if is_tool_result then + local context_management = msg._meta and msg._meta.context_management + local already_edited = context_management and context_management.edited + local cycle = msg._meta and msg._meta.cycle + local tool_name = tool_names[msg.tools.call_id] + local excluded = tool_name and exclude[tool_name] + + if not already_edited and not excluded and cycle and cycle <= cutoff then + msg.content = placeholder + msg._meta.estimated_tokens = tokens.calculate(placeholder) + msg._meta.context_management = msg._meta.context_management or {} + msg._meta.context_management.edited = true + cleared = cleared + 1 + end + end + end + + return cleared +end + +---Replace aged messages +---@param messages CodeCompanion.Chat.Messages +---@param opts CodeCompanion.ContextManagement.Editing.Opts +---@return CodeCompanion.Chat.Messages messages +---@return number Number of messages cleared +function M.apply(messages, opts) + local cleared = tool_results(messages, opts) + return messages, cleared +end + +return M diff --git a/lua/codecompanion/interactions/chat/helpers/init.lua b/lua/codecompanion/interactions/chat/helpers/init.lua index 6c5e1ae5f..367824b13 100644 --- a/lua/codecompanion/interactions/chat/helpers/init.lua +++ b/lua/codecompanion/interactions/chat/helpers/init.lua @@ -345,28 +345,34 @@ function M.format_viewport_for_llm(buf_lines) return table.concat(formatted, "\n\n") end ----Returns the number of tokens that trigger context management +---Returns the number of tokens that trigger context management for a given operation ---@param adapter CodeCompanion.HTTPAdapter +---@param opts? { operation?: "editing"|"compaction" } defaults to "compaction" ---@return number -function M.trigger_context_management(adapter) - if adapter.type ~= "http" then +function M.trigger_context_management(adapter, opts) + opts = opts or {} + local operation = opts.operation or "compaction" + + local context_management = config.interactions.chat.opts.context_management + local settings = context_management and context_management[operation] + local trigger = settings and settings.trigger + if trigger == nil then return 0 end - local ok - local trigger_tokens = config.interactions.chat.opts.context_management.trigger - if trigger_tokens < 1 then - ok, trigger_tokens = pcall(function() - return math.floor(trigger_tokens * adapter.schema.model.choices[adapter.schema.model.default].meta.context_window) + if trigger < 1 then + local ok + ok, trigger = pcall(function() + return math.floor(trigger * adapter.schema.model.choices[adapter.schema.model.default].meta.context_window) end) if not ok then - log:error("Could not get evaluate the trigger for context management in the `%s` adapter", adapter.name) + log:error("Could not evaluate the %s trigger for context management in the `%s` adapter", operation, adapter.name) return 0 end end - return trigger_tokens + return trigger end return M diff --git a/lua/codecompanion/utils/init.lua b/lua/codecompanion/utils/init.lua index fb6933819..8a5d498ae 100644 --- a/lua/codecompanion/utils/init.lua +++ b/lua/codecompanion/utils/init.lua @@ -195,14 +195,13 @@ function M.parse_iso8601(iso) return nil end - ---@type osdateparam local date = { - year = tonumber(year) --[[@as integer]], - month = tonumber(month) --[[@as integer]], - day = tonumber(day) --[[@as integer]], - hour = tonumber(hour) --[[@as integer]], - min = tonumber(min) --[[@as integer]], - sec = tonumber(sec) --[[@as integer]], + year = tonumber(year), --[[@as number]] + month = tonumber(month), --[[@as number]] + day = tonumber(day), --[[@as number]] + hour = tonumber(hour), --[[@as number]] + min = tonumber(min), --[[@as number]] + sec = tonumber(sec), --[[@as number]] } return os.time(date) diff --git a/tests/interactions/chat/context_management/test_editing.lua b/tests/interactions/chat/context_management/test_editing.lua new file mode 100644 index 000000000..8884cfce3 --- /dev/null +++ b/tests/interactions/chat/context_management/test_editing.lua @@ -0,0 +1,431 @@ +local Editing = require("codecompanion.interactions.chat.context_management.editing") +local h = require("tests.helpers") + +local child = MiniTest.new_child_neovim() +local T = MiniTest.new_set() + +local function user_msg(cycle, content) + return { + role = "user", + content = content or "Hello", + _meta = { cycle = cycle, id = 1 }, + opts = { visible = true }, + } +end + +local function llm_msg(cycle, content) + return { + role = "llm", + content = content or "Sure thing", + _meta = { cycle = cycle, id = 2 }, + opts = { visible = true }, + } +end + +local function tool_call_msg(cycle, calls) + return { + role = "llm", + content = "", + _meta = { cycle = cycle, id = 3 }, + opts = { visible = false }, + tools = { calls = calls }, + } +end + +local function tool_call(id, name, args) + return { + id = id, + type = "function", + ["function"] = { name = name, arguments = args or "{}" }, + } +end + +local function tool_result_msg(cycle, call_id, content) + return { + role = "tool", + content = content or "result body", + _meta = { cycle = cycle, id = math.random(1e9) }, + opts = { visible = true }, + tools = { call_id = call_id, is_error = false, type = "tool_result" }, + } +end + +T["Editing"] = MiniTest.new_set() + +T["Editing"]["returns empty input unchanged"] = function() + local messages, cleared = Editing.apply({}, { current_cycle = 1, keep_cycles = 3 }) + h.eq({}, messages) + h.eq(0, cleared) +end + +T["Editing"]["leaves messages alone when there are no tool results"] = function() + local messages = { user_msg(1), llm_msg(1), user_msg(2), llm_msg(2) } + local before = vim.deepcopy(messages) + local _, cleared = Editing.apply(messages, { current_cycle = 5, keep_cycles = 3 }) + h.eq(before, messages) + h.eq(0, cleared) +end + +T["Editing"]["keeps tool results within the keep_cycles window"] = function() + -- 3 cycles, keep_cycles = 3, current = 3 → cutoff = 0 → keep everything + local messages = { + user_msg(1), + tool_call_msg(1, { tool_call("c1", "read_file") }), + tool_result_msg(1, "c1", "file contents 1"), + user_msg(2), + tool_call_msg(2, { tool_call("c2", "read_file") }), + tool_result_msg(2, "c2", "file contents 2"), + user_msg(3), + tool_call_msg(3, { tool_call("c3", "read_file") }), + tool_result_msg(3, "c3", "file contents 3"), + } + local before = vim.deepcopy(messages) + local _, cleared = Editing.apply(messages, { current_cycle = 3, keep_cycles = 3 }) + h.eq(0, cleared) + h.eq(before, messages) +end + +T["Editing"]["clears tool results outside the keep_cycles window"] = function() + -- current = 8, keep_cycles = 3 → keep 6,7,8 / clean 1..5 + local messages = { + tool_call_msg(1, { tool_call("c1", "read_file") }), + tool_result_msg(1, "c1", "old result 1"), + tool_call_msg(5, { tool_call("c5", "read_file") }), + tool_result_msg(5, "c5", "old result 5"), + tool_call_msg(6, { tool_call("c6", "read_file") }), + tool_result_msg(6, "c6", "kept result 6"), + tool_call_msg(8, { tool_call("c8", "read_file") }), + tool_result_msg(8, "c8", "kept result 8"), + } + local _, cleared = Editing.apply(messages, { current_cycle = 8, keep_cycles = 3 }) + h.eq(2, cleared) + h.eq(Editing.PLACEHOLDERS.tool_result, messages[2].content) + h.eq(Editing.PLACEHOLDERS.tool_result, messages[4].content) + h.eq("kept result 6", messages[6].content) + h.eq("kept result 8", messages[8].content) +end + +T["Editing"]["preserves excluded tools regardless of cycle"] = function() + local messages = { + tool_call_msg(1, { tool_call("c1", "memory") }), + tool_result_msg(1, "c1", "memory output"), + tool_call_msg(1, { tool_call("c2", "read_file") }), + tool_result_msg(1, "c2", "file output"), + } + local _, cleared = Editing.apply(messages, { + current_cycle = 8, + keep_cycles = 3, + exclude_tools = { "memory" }, + }) + h.eq(1, cleared) + h.eq("memory output", messages[2].content) + h.eq(Editing.PLACEHOLDERS.tool_result, messages[4].content) +end + +T["Editing"]["never touches tool calls, only results"] = function() + local call_msg = tool_call_msg(1, { tool_call("c1", "read_file") }) + local messages = { call_msg, tool_result_msg(1, "c1", "result") } + local before_calls = vim.deepcopy(call_msg.tools.calls) + Editing.apply(messages, { current_cycle = 8, keep_cycles = 3 }) + h.eq(before_calls, messages[1].tools.calls) + h.eq("", messages[1].content) +end + +T["Editing"]["leaves user/llm content untouched even when aged"] = function() + local messages = { + user_msg(1, "an ancient prompt"), + llm_msg(1, "an ancient reply"), + tool_call_msg(1, { tool_call("c1", "read_file") }), + tool_result_msg(1, "c1", "ancient tool output"), + } + local _, cleared = Editing.apply(messages, { current_cycle = 10, keep_cycles = 3 }) + h.eq(1, cleared) + h.eq("an ancient prompt", messages[1].content) + h.eq("an ancient reply", messages[2].content) + h.eq(Editing.PLACEHOLDERS.tool_result, messages[4].content) +end + +T["Editing"]["marks edited messages and skips them on re-run"] = function() + local messages = { + tool_call_msg(1, { tool_call("c1", "read_file") }), + tool_result_msg(1, "c1", "result"), + } + local _, first = Editing.apply(messages, { current_cycle = 8, keep_cycles = 3 }) + h.eq(1, first) + h.is_true(messages[2]._meta.context_management.edited) + + -- Mutate to look like a re-edit attempt with the same placeholder + local _, second = Editing.apply(messages, { current_cycle = 8, keep_cycles = 3 }) + h.eq(0, second) +end + +T["Editing"]["skips tool results without a cycle (defensive)"] = function() + local result = tool_result_msg(1, "c1", "result") + result._meta.cycle = nil + local messages = { tool_call_msg(1, { tool_call("c1", "read_file") }), result } + local _, cleared = Editing.apply(messages, { current_cycle = 8, keep_cycles = 3 }) + h.eq(0, cleared) + h.eq("result", result.content) +end + +T["Editing"]["updates estimated_tokens when content is replaced"] = function() + local messages = { + tool_call_msg(1, { tool_call("c1", "read_file") }), + tool_result_msg(1, "c1", string.rep("x ", 500)), + } + messages[2]._meta.estimated_tokens = 9999 + Editing.apply(messages, { current_cycle = 8, keep_cycles = 3 }) + h.not_eq(9999, messages[2]._meta.estimated_tokens) +end + +T["Editing.integration"] = MiniTest.new_set({ + hooks = { + pre_case = function() + h.child_start(child) + end, + post_once = child.stop, + }, +}) + +T["Editing.integration"]["multi-cycle chat history"] = function() + child.lua([==[ + local Editing = require("codecompanion.interactions.chat.context_management.editing") + local tokens = require("codecompanion.utils.tokens") + local placeholder = Editing.PLACEHOLDERS.tool_result + local placeholder_tokens = tokens.calculate(placeholder) + local long_file = string.rep("file contents line\n", 100) + + _G.messages = { + -- [1] cycle 1: user prompt + { + _meta = { cycle = 1, id = 101 }, + content = "Can you find lua files and do a grep search for `function`", + opts = { visible = true }, + role = "user", + }, + -- [2] cycle 1: llm text + { + _meta = { cycle = 1, id = 102 }, + content = "I'll search for those.", + opts = { visible = true }, + role = "llm", + }, + -- [3] cycle 1: llm fires two tool calls at once + { + _meta = { cycle = 1, id = 103 }, + content = "", + opts = { visible = false }, + role = "llm", + tools = { + calls = { + { + id = "c1a", + type = "function", + ["function"] = { arguments = '{"pattern":"*.lua"}', name = "file_search" }, + }, + { + id = "c1b", + type = "function", + ["function"] = { arguments = '{"pattern":"function"}', name = "grep_search" }, + }, + }, + }, + }, + -- [4] cycle 1: tool result for c1a (will be edited) + { + _meta = { cycle = 1, id = 104 }, + content = "init.lua\nutils.lua\nconfig.lua", + opts = { visible = true }, + role = "tool", + tools = { call_id = "c1a", is_error = false, type = "tool_result" }, + }, + -- [5] cycle 1: tool result for c1b (will be edited) + { + _meta = { cycle = 1, id = 105 }, + content = "init.lua:1 function setup", + opts = { visible = true }, + role = "tool", + tools = { call_id = "c1b", is_error = false, type = "tool_result" }, + }, + + -- [6] cycle 2: user prompt + { + _meta = { cycle = 2, id = 201 }, + content = "Read init.lua", + opts = { visible = true }, + role = "user", + }, + -- [7] cycle 2: llm text + { + _meta = { cycle = 2, id = 202 }, + content = "Reading init.lua now.", + opts = { visible = true }, + role = "llm", + }, + -- [8] cycle 2: llm tool call + { + _meta = { cycle = 2, id = 203 }, + content = "", + opts = { visible = false }, + role = "llm", + tools = { + calls = { + { + id = "c2", + type = "function", + ["function"] = { arguments = '{"path":"init.lua"}', name = "read_file" }, + }, + }, + }, + }, + -- [9] cycle 2: tool result (will be edited) + { + _meta = { cycle = 2, id = 204 }, + content = long_file, + opts = { visible = true }, + role = "tool", + tools = { call_id = "c2", is_error = false, type = "tool_result" }, + }, + + -- [10] cycle 3: user prompt + { + _meta = { cycle = 3, id = 301 }, + content = "Remember that init.lua is the entry point", + opts = { visible = true }, + role = "user", + }, + -- [11] cycle 3: llm tool call to the memory tool + { + _meta = { cycle = 3, id = 302 }, + content = "", + opts = { visible = false }, + role = "llm", + tools = { + calls = { + { + id = "c3", + type = "function", + ["function"] = { arguments = '{"note":"init is entry"}', name = "memory" }, + }, + }, + }, + }, + -- [12] cycle 3: tool result for memory (excluded, will survive) + { + _meta = { cycle = 3, id = 303 }, + content = "Saved: init is entry", + opts = { visible = true }, + role = "tool", + tools = { call_id = "c3", is_error = false, type = "tool_result" }, + }, + + -- [13] cycle 4: user prompt + { + _meta = { cycle = 4, id = 401 }, + content = "Grep for `require`", + opts = { visible = true }, + role = "user", + }, + -- [14] cycle 4: llm tool call + { + _meta = { cycle = 4, id = 402 }, + content = "", + opts = { visible = false }, + role = "llm", + tools = { + calls = { + { + id = "c4", + type = "function", + ["function"] = { arguments = '{"pattern":"require"}', name = "grep_search" }, + }, + }, + }, + }, + -- [15] cycle 4: tool result (kept by keep_cycles) + { + _meta = { cycle = 4, id = 403 }, + content = "matches in 12 files", + opts = { visible = true }, + role = "tool", + tools = { call_id = "c4", is_error = false, type = "tool_result" }, + }, + + -- [16] cycle 5: user prompt + { + _meta = { cycle = 5, id = 501 }, + content = "What does that file do?", + opts = { visible = true }, + role = "user", + }, + -- [17] cycle 5: llm text only + { + _meta = { cycle = 5, id = 502 }, + content = "It bootstraps the plugin.", + opts = { visible = true }, + role = "llm", + }, + + -- [18] cycle 6: user prompt + { + _meta = { cycle = 6, id = 601 }, + content = "Read it once more", + opts = { visible = true }, + role = "user", + }, + -- [19] cycle 6: llm tool call + { + _meta = { cycle = 6, id = 602 }, + content = "", + opts = { visible = false }, + role = "llm", + tools = { + calls = { + { + id = "c6", + type = "function", + ["function"] = { arguments = '{"path":"init.lua"}', name = "read_file" }, + }, + }, + }, + }, + -- [20] cycle 6: tool result (kept by keep_cycles) + { + _meta = { cycle = 6, id = 603 }, + content = "fresh contents", + opts = { visible = true }, + role = "tool", + tools = { call_id = "c6", is_error = false, type = "tool_result" }, + }, + } + + -- Build the expected post-edit state by snapshotting input and mutating + -- only the tool results we expect to be cleared (cycles 1-2; cycle 3's + -- memory result is excluded; cycles 4-6 are kept by keep_cycles). + _G.expected = vim.deepcopy(_G.messages) + for _, idx in ipairs({ 4, 5, 9 }) do + _G.expected[idx].content = placeholder + _G.expected[idx]._meta.estimated_tokens = placeholder_tokens + _G.expected[idx]._meta.context_management = { edited = true } + end + + _G.first_cleared = select(2, Editing.apply(_G.messages, { + current_cycle = 6, + exclude_tools = { "memory" }, + keep_cycles = 3, + })) + + -- Re-running on the same chat clears nothing — already-edited results are skipped + _G.second_cleared = select(2, Editing.apply(_G.messages, { + current_cycle = 6, + exclude_tools = { "memory" }, + keep_cycles = 3, + })) + ]==]) + + h.eq(3, child.lua_get("_G.first_cleared")) + h.eq(0, child.lua_get("_G.second_cleared")) + h.eq(child.lua_get("_G.expected"), child.lua_get("_G.messages")) +end + +return T