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