diff --git a/.codecompanion/chat.md b/.codecompanion/chat.md index 81103693a..f26e93011 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 ea80e1eeb..6661d8f4d 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 @@ -1355,6 +1362,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/compaction.lua b/lua/codecompanion/interactions/chat/context_management/compaction.lua new file mode 100644 index 000000000..53f105da5 --- /dev/null +++ b/lua/codecompanion/interactions/chat/context_management/compaction.lua @@ -0,0 +1,406 @@ +--[[ +=============================================================================== + File: codecompanion/interactions/chat/context_management/compaction.lua + Author: Oli Morris +------------------------------------------------------------------------------- + Description: + Replaces the conversation history with an LLM-generated summary. System + messages and rules pass through verbatim; files, buffers, and images + are swapped for reference placeholders, useful in future turns. +------------------------------------------------------------------------------- + Attribution: + If you use or distribute this code, please credit: + Oli Morris (https://github.com/olimorris) +=============================================================================== +--]] + +local config = require("codecompanion.config") +local log = require("codecompanion.utils.log") +local tags = require("codecompanion.interactions.shared.tags") +local tokens = require("codecompanion.utils.tokens") +local utils = require("codecompanion.utils") + +local fmt = string.format + +local CONSTANTS = { + MIN_TOKEN_SAVINGS = 10000, + + -- This is based on the Claude Code compaction prompt (Ref: https://github.com/Piebald-AI/claude-code-system-prompts) + PROMPT = [[Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions. +This summary should be thorough in capturing technical details, code patterns, and architectural decisions that would be essential for continuing development work without losing context. + +Before providing your final summary, you must first perform an analysis of the conversation, wrapped in tags. Walk through the conversation chronologically, identifying explicit user requests, your responses, key decisions, and specific details like file paths, code snippets, and function signatures. This grounds your summary in the actual conversation rather than guessing. + +Your summary should include the following sections: + +1. Primary Request and Intent: Capture all of the user's explicit requests and intents in detail. +2. Key Technical Concepts: List all important technical concepts, technologies, and frameworks discussed. +3. Files and Code Sections: Enumerate specific files and code sections examined, modified, or created. Pay special attention to the most recent messages and include full code snippets where applicable and a summary of why each file read or edit is important. +4. Errors and fixes: List all errors that you ran into, and how you fixed them. Pay special attention to specific user feedback that you received, especially if the user told you to do something differently. +5. Problem Solving: Document problems solved and any ongoing troubleshooting efforts. +6. All user messages: List ALL user messages that are not tool results. These are critical for understanding the users' feedback and changing intent. +7. Pending Tasks: Outline any pending tasks that you have explicitly been asked to work on. +8. Current Work: Describe in detail precisely what was being worked on immediately before this summary request, paying special attention to the most recent messages from both user and assistant. Include file names and code snippets where applicable. +9. Optional Next Step: List the next step that you will take that is related to the most recent work you were doing. IMPORTANT: ensure that this step is DIRECTLY in line with the user's most recent explicit requests, and the task you were working on immediately before this summary request. If your last task was concluded, then only list next steps if they are explicitly in line with the user's request. Do not start on tangential requests without confirming with the user first. If there is a next step, include direct quotes from the most recent conversation showing exactly what task you were working on and where you left off. This should be verbatim to ensure there's no drift in task interpretation. + +Here's an example of how your output should be structured: + + + +[Your thought process, ensuring all points are covered thoroughly and accurately] + + + +1. Primary Request and Intent: + [Detailed description] + +2. Key Technical Concepts: + - [Concept 1] + - [Concept 2] + +3. Files and Code Sections: + - [File Name 1] + - [Summary of why this file is important] + - [Summary of the changes made to this file, if any] + +````[language] +[Important Code Snippet] +```` + +4. Errors and fixes: + - [Detailed description of error 1]: + - [How you fixed the error] + - [User feedback on the error if any] + +5. Problem Solving: + [Description of solved problems and ongoing troubleshooting] + +6. All user messages: + - [Detailed non tool use user message] + +7. Pending Tasks: + - [Task 1] + +8. Current Work: + [Precise description of current work] + +9. Optional Next Step: + [Optional Next step to take] + + + +Please provide your summary based on the conversation so far, following this structure and ensuring precision and thoroughness in your response. + +Include the markdown only, without any additional commentary or explanation. If you're referencing any code in your summary, ensure that it is wrapped in FOUR backticks followed by the appropriate language identifier for syntax highlighting. For example: + +````python +def example_function(): + pass +```` + +The conversation and corresponding messages to summarize, is as follows: + +%s]], + SUMMARY_PREFIX = "Below is a summary of a previous conversation:\n\n", +} + +---@class CodeCompanion.Chat.ContextManagement.Compaction +local M = {} + +M.PLACEHOLDERS = { + buffer = "Buffer content cleared during compaction. Request the buffer from the user if you need it.", + file = "File content cleared during compaction. Re-read the file if you need it.", + image = "Image content cleared during compaction. Request the image from the user if you need it.", +} + +---Calls the background interaction. Abstracted for easier mocking +local function background() + return require("codecompanion.interactions.background") +end + +---@alias CodeCompanion.Chat.ContextManagement.Compaction.Kind +---| "keep" +---| "drop" +---| "compacted_file" +---| "compacted_buffer" +---| "compacted_image" +---| "stale_summary" -- Previous summary message which should be removed on the next compaction cycle + +---Classify a message for compaction +---@param message CodeCompanion.Chat.Message +---@return CodeCompanion.Chat.ContextManagement.Compaction.Kind +local function classify(message) + local meta = message._meta or {} + local context_management = meta.context_management or {} + + if message.role == config.constants.SYSTEM_ROLE then + return "keep" + end + if context_management.compacted then + return "keep" + end + + local tag = meta.tag + if tag == tags.RULES then + return "keep" + end + if tag == tags.COMPACT_SUMMARY then + return "stale_summary" + end + if tag == tags.FILE then + return "compacted_file" + end + if tag == tags.BUFFER or tag == tags.EDITOR_CONTEXT then + return "compacted_buffer" + end + if tag == tags.IMAGE then + return "compacted_image" + end + + return "drop" +end + +---Build the message list that will remain in the chat after compaction, excluding the summary +---@param messages CodeCompanion.Chat.Messages +---@return CodeCompanion.Chat.Messages +local function messages_to_retain(messages) + local retained = {} + + for _, message in ipairs(messages) do + local kind = classify(message) + + if kind == "keep" then + table.insert(retained, message) + elseif kind == "stale_summary" or kind == "drop" then + -- excluded + else + local placeholder + if kind == "compacted_file" then + placeholder = M.PLACEHOLDERS.file + elseif kind == "compacted_buffer" then + placeholder = M.PLACEHOLDERS.buffer + elseif kind == "compacted_image" then + placeholder = M.PLACEHOLDERS.image + end + + local replaced = vim.deepcopy(message) + replaced.content = placeholder + replaced._meta = replaced._meta or {} + replaced._meta.estimated_tokens = tokens.calculate(placeholder) + replaced._meta.context_management = replaced._meta.context_management or {} + replaced._meta.context_management.compacted = true + + if kind == "compacted_image" then + -- The base64 payload and image-specific context are no longer needed. + replaced.context = nil + end + + table.insert(retained, replaced) + end + end + + return retained +end + +---Curate the messages that will be used to generate the summary +---@param messages CodeCompanion.Chat.Messages +---@return string +local function messages_to_summarize(messages) + local parts = {} + + for _, message in ipairs(messages) do + local meta = message._meta or {} + + if message.role == config.constants.SYSTEM_ROLE then + goto continue + end + -- Strip images as the summarizer may not support them and the base64 content is expensive + if meta.tag == tags.IMAGE then + goto continue + end + + local content_parts = {} + + if message.tools and message.tools.calls then + for _, call in ipairs(message.tools.calls) do + local fn = call["function"] + if fn and fn.name then + table.insert(content_parts, fmt("Tool: %s(%s)", fn.name, fn.arguments or "{}")) + end + end + end + + if type(message.content) == "string" and message.content ~= "" then + table.insert(content_parts, message.content) + end + + if #content_parts > 0 then + table.insert(parts, fmt('%s', message.role, table.concat(content_parts, "\n"))) + end + + ::continue:: + end + + return table.concat(parts, "") +end + +---Estimate the token savings from the compaction +---@param original CodeCompanion.Chat.Messages +---@param retained CodeCompanion.Chat.Messages +---@return number +local function estimate_savings(original, retained) + local before = tokens.get_tokens(original) + local after = tokens.get_tokens(retained) + return math.max(0, before - after) +end + +---@param chat CodeCompanion.Chat +---@param override? string|table +---@return CodeCompanion.HTTPAdapter|string|table +local function resolve_adapter(chat, override) + if override == nil then + return chat.adapter + end + return override +end + +---@class CodeCompanion.Chat.ContextManagement.Compaction.RequestOpts +---@field adapter CodeCompanion.HTTPAdapter|string|table +---@field messages_text string The chat messages, formatted for the summariser +---@field on_done fun(content: string|nil) +---@field on_error fun(err: any) + +---Ask an LLM to summarize the chat buffer's messages +---@param request CodeCompanion.Chat.ContextManagement.Compaction.RequestOpts +---@return nil +local function request_summary(request) + local prompt = fmt(CONSTANTS.PROMPT, request.messages_text) + + background().new({ adapter = request.adapter }):ask({ + { role = config.constants.USER_ROLE, content = prompt }, + }, { + method = "async", + on_done = function(result) + local content = result and result.output and result.output.content + request.on_done(content) + end, + on_error = request.on_error, + }) +end + +---@class CodeCompanion.Chat.ContextManagement.Compaction.Opts +---@field adapter? string|table Override adapter (nil | "name" | { name, model }) +---@field fallback_to_chat_adapter? boolean Silently retry with the chat adapter on failure (default false) +---@field min_token_savings? number Skip if estimated savings under this (default 10000) + +---Run compaction on a chat buffer +---@param chat CodeCompanion.Chat +---@param opts? CodeCompanion.Chat.ContextManagement.Compaction.Opts +---@return nil +function M.compact(chat, opts) + opts = opts or {} + + if chat.adapter and chat.adapter.type == "acp" then + return log:debug("[Compaction] Skipped — ACP adapters handle context themselves") + end + if chat._compacting then + return log:debug("[Compaction] Skipped — a compaction is already in progress") + end + + local original = chat.messages or {} + local retained = messages_to_retain(original) + local min_token_savings = opts.min_token_savings or CONSTANTS.MIN_TOKEN_SAVINGS + local savings = estimate_savings(original, retained) + + if savings < min_token_savings then + return log:warn("[Compaction] Skipped — estimated savings (%d) below threshold (%d)", savings, min_token_savings) + end + + local messages_text = messages_to_summarize(original) + if messages_text == "" then + return log:warn("[Compaction] Skipped — nothing to summarise") + end + + chat._compacting = true + + local primary = resolve_adapter(chat, opts.adapter) + + ---Apply the summary to the chat buffer and re-render the UI + ---@param content string + ---@return nil + local function update_chat(content) + local body = CONSTANTS.SUMMARY_PREFIX .. content + table.insert(retained, { + role = config.constants.USER_ROLE, + content = body, + opts = { visible = true }, + _meta = { + cycle = chat.cycle, + estimated_tokens = tokens.calculate(body), + tag = tags.COMPACT_SUMMARY, + }, + }) + chat.messages = retained + chat._compacting = false + if chat.ui and chat.ui.render then + chat.ui:render(chat.buffer_context, chat.messages, chat.opts) + end + return utils.notify("Chat compacted") + end + + ---Handle a failure in the compaction process + ---@param reason string A message describing the failure reason + ---@return nil + local function fail(reason) + chat._compacting = false + return log:error("[Compaction] Failed: %s", reason) + end + + ---Determine if we should attempt a fallback to the chat adapter on failure + ---@return boolean + local function should_fallback() + return opts.fallback_to_chat_adapter == true and primary ~= chat.adapter + end + + ---Run the fallback adapter if the primary fails or returns empty content + ---@param reason string + ---@return nil + local function run_fallback(reason) + log:debug("[Compaction] Falling back to chat adapter (%s)", reason) + request_summary({ + adapter = chat.adapter, + messages_text = messages_text, + on_done = function(content) + if content and content ~= "" then + update_chat(content) + else + fail("fallback adapter returned empty content") + end + end, + on_error = fail, + }) + end + + request_summary({ + adapter = primary, + messages_text = messages_text, + on_done = function(content) + if content and content ~= "" then + update_chat(content) + elseif should_fallback() then + run_fallback("primary adapter returned empty content") + else + fail("compaction adapter returned empty content") + end + end, + on_error = function(err) + if should_fallback() then + run_fallback(err) + else + fail(err) + end + end, + }) +end + +return M 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..2671e5a1d --- /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.Chat.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.Chat.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.Chat.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/interactions/chat/init.lua b/lua/codecompanion/interactions/chat/init.lua index bf2e031c7..6a063ad8f 100644 --- a/lua/codecompanion/interactions/chat/init.lua +++ b/lua/codecompanion/interactions/chat/init.lua @@ -36,6 +36,7 @@ ---@field ui CodeCompanion.Chat.UI The UI of the chat buffer ---@field window_opts? table Window configuration options for the chat buffer ---@field yaml_parser vim.treesitter.LanguageTree The Yaml Tree-sitter parser for the chat buffer +---@field _compacting? boolean Whether a compaction request is currently in flight ---@field _last_role string The last role that was rendered in the chat buffer ---@field _tool_monitors? table A table of tool monitors that are currently running in the chat buffer diff --git a/lua/codecompanion/interactions/chat/slash_commands/builtin/compact.lua b/lua/codecompanion/interactions/chat/slash_commands/builtin/compact.lua index f1725cba3..f994f51b5 100644 --- a/lua/codecompanion/interactions/chat/slash_commands/builtin/compact.lua +++ b/lua/codecompanion/interactions/chat/slash_commands/builtin/compact.lua @@ -1,150 +1,20 @@ -local config = require("codecompanion.config") -local log = require("codecompanion.utils.log") -local tags = require("codecompanion.interactions.shared.tags") - -local fmt = string.format - -local CONSTANTS = { - -- Ref: https://www.reddit.com/r/ClaudeAI/comments/1jr52qj/here_is_claude_codes_compact_prompt/ - PROMPT = [[Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions. -This summary should be thorough in capturing technical details, code patterns, and architectural decisions that would be essential for continuing development work without losing context. - -Before providing your final summary, you must first perform an analysis of the conversation, outputting a summary. - -The "summary" should be a Markdown-formatted summary. It must include the following sections, each with a Markdown header: - -- "Primary Request and Intent": Capture all of the user's explicit requests and intents in detail. -- "Key Technical Concepts": List all important technical concepts, technologies, and frameworks discussed. -- "Files and Code Sections": Enumerate specific files and code sections examined, modified, or created. Pay special attention to the most recent messages and include full code snippets where applicable and include a summary of why this file read or edit is important. -- "Problem Solving": Document problems solved and any ongoing troubleshooting efforts. -- "Pending Tasks": Outline any pending tasks that you have explicitly been asked to work on. -- "Current Work": Describe in detail precisely what was being worked on immediately before this summary request, paying special attention to the most recent messages from both user and assistant. Include file names and code snippets where applicable. -- "Optional Next Step": List the next step that you will take that is related to the most recent work you were doing. IMPORTANT: ensure that this step is DIRECTLY in line with the user's explicit requests, and the task you were working on immediately before this summary request. If your last task was concluded, then only list next steps if they are explicitly in line with the users request. Do not start on tangential requests without confirming with the user first. -- "Supporting Quotes": If there is a next step, include direct quotes from the most recent conversation showing exactly what task you were working on and where you left off. This should be verbatim to ensure there's no drift in task interpretation. - -Here's an example of how a summary might be structured: - ---- -### Primary Request and Intent - -[Detailed description] - -### Key Technical Concepts - -- [Concept 1] -- [Concept 2] - -### Files and Code Sections - -- **[File Name 1]** -- [Summary of why this file is important] -- [Summary of the changes made to this file, if any] - -````[language] -[Important Code Snippet] -```` - -### Problem Solving - -[Description of solved problems and ongoing troubleshooting] - -### Pending Tasks - -- [Task 1] -- [Task 2] - -### Current Work - -[Precise description of current work] - -### Optional Next Step - -[Optional Next step to take] - -### Supporting Quotes - -> [Verbatim quotes from the conversation] ---- - -Please provide your summary based on the conversation so far, following this structure and ensuring precision and thoroughness in your response. - -Include the markdown only, without any additional commentary or explanation and no markdown formatting. If you're referencing any code in your summary, ensure that is wrapped in FOUR backticks followed by the appropriate language identifier for syntax highlighting. For example: - -````python -def example_function(): - pass -```` - -The conversation and corresponding messages to summarize, is as follows: - -%s]], -} +local Compaction = require("codecompanion.interactions.chat.context_management.compaction") ---@class CodeCompanion.SlashCommand.Compact: CodeCompanion.SlashCommand local SlashCommand = {} ---@param args CodeCompanion.SlashCommandArgs function SlashCommand.new(args) - local self = setmetatable({ + return setmetatable({ Chat = args.Chat, config = args.config, context = args.context, }, { __index = SlashCommand }) - - return self -end - ----Create the conversation string from messages ----@param messages CodeCompanion.Chat.Messages ----@return string -function SlashCommand:create_conversation(messages) - --Rules: - --1. We only care about user and assistant messages - local conversation = "" - - for _, message in ipairs(messages or {}) do - if message.role == "user" or message.role == "assistant" then - conversation = conversation .. fmt('%s', message.role, message.content) - end - end - - return conversation -end - ----Compact the messages, removing user and llm roles ----@return nil -function SlashCommand:compact_messages() - --Rules: - --1. Keep ALL system messages even if they come from tools - --2. Remove ALL llm messages - --3. Keep SOME user messages - If it has a "variable", "rules" or "file" tag - local ok_tags = { tags.EDITOR_CONTEXT, tags.RULES, tags.FILE } - - local messages = vim.iter(self.Chat.messages):filter(function(message) - if message.role == config.constants.SYSTEM_ROLE then - return true - elseif message.role == config.constants.LLM_ROLE then - return false - elseif message.role == config.constants.USER_ROLE then - if message._meta and message._meta.tag then - if vim.tbl_contains(ok_tags, message._meta.tag) then - return true - else - return false - end - end - end - - return false - end) - - self.Chat.messages = messages:totable() end ---Execute the slash command ----@param SlashCommands CodeCompanion.SlashCommands ---@return nil -function SlashCommand:execute(SlashCommands) +function SlashCommand:execute() return vim.ui.select({ "Yes", "No" }, { kind = "codecompanion.nvim", prompt = "Generate a compact summary of the conversation so far?", @@ -152,38 +22,7 @@ function SlashCommand:execute(SlashCommands) if not selected or selected == "No" then return end - - local request = require("codecompanion.interactions.background") - .new({ - adapter = self.Chat.adapter, - }) - :ask({ - { - role = "user", - content = fmt(CONSTANTS.PROMPT, self:create_conversation(self.Chat.messages)), - }, - }, { - method = "async", - on_done = function(result) - if result then - local content = result.output and result.output.content - - -- I don't care about the analysis field. I include that in the - -- prompt to guide the model's thinking process. - self.Chat:add_buf_message({ - role = config.constants.USER_ROLE, - content = fmt("Below is a summary of a conversation we've previously had:\n\n%s\n\n", content), - }) - self:compact_messages() - - return log:debug("[Compact] Compacted the chat history") - end - log:debug("[Compact] No result from compacting the conversation") - end, - on_error = function(err) - return log:error("[Compact] Error compacting the conversation: %s", err) - end, - }) + Compaction.compact(self.Chat, { min_token_savings = 0 }) end) end 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_compaction.lua b/tests/interactions/chat/context_management/test_compaction.lua new file mode 100644 index 000000000..5ad921196 --- /dev/null +++ b/tests/interactions/chat/context_management/test_compaction.lua @@ -0,0 +1,441 @@ +local h = require("tests.helpers") + +local child = MiniTest.new_child_neovim() +local T = MiniTest.new_set({ + hooks = { + pre_case = function() + h.child_start(child) + end, + post_once = child.stop, + }, +}) + +T["Compaction"] = MiniTest.new_set() + +T["Compaction"]["replaces the chat with placeholders and a tagged summary"] = function() + child.lua([==[ + -- Stub the Background module so the LLM call resolves synchronously with mock content + package.loaded["codecompanion.interactions.background"] = { + new = function() + return { + ask = function(_, _, opts) + opts.on_done({ output = { content = "Mock summary content" } }) + end, + } + end, + } + + local Compaction = require("codecompanion.interactions.chat.context_management.compaction") + local tags = require("codecompanion.interactions.shared.tags") + local file_body = string.rep("file body line\n", 800) + + _G.chat = { + adapter = { type = "http", name = "fake" }, + buffer_context = {}, + cycle = 7, + opts = {}, + ui = { render = function(self) return self end }, + messages = { + -- System prompt passes through + { + role = "system", + content = "system prompt", + opts = { visible = false }, + _meta = { cycle = 1, tag = tags.SYSTEM_PROMPT_FROM_CONFIG }, + }, + -- Project rules passes through + { + role = "user", + content = "Project rules", + opts = { visible = true }, + _meta = { cycle = 1, tag = tags.RULES }, + }, + -- File replaced with placeholder + { + role = "user", + content = file_body, + opts = { visible = true }, + _meta = { cycle = 1, tag = tags.FILE }, + }, + -- Buffer replaced with placeholder + { + role = "user", + content = file_body, + opts = { visible = true }, + _meta = { cycle = 1, tag = tags.BUFFER }, + }, + -- User prompt dropped and summarised + { + role = "user", + content = "Tell me about the file", + opts = { visible = true }, + _meta = { cycle = 1 }, + }, + -- LLM reply dropped and summarised + { + role = "llm", + content = "It contains 800 repeated lines", + opts = { visible = true }, + _meta = { cycle = 1 }, + }, + }, + } + + -- NOTE: min_token_savings is set low so this test can run + Compaction.compact(_G.chat, { min_token_savings = 1 }) + + _G.placeholder_file = Compaction.PLACEHOLDERS.file + _G.placeholder_buffer = Compaction.PLACEHOLDERS.buffer + _G.compact_summary_tag = tags.COMPACT_SUMMARY + ]==]) + + local messages = child.lua_get("_G.chat.messages") + + -- 4 retained messages + 1 new summary + h.eq(5, #messages) + + -- system + rules preserved + h.eq("system prompt", messages[1].content) + h.eq("Project rules", messages[2].content) + + -- file + buffer swapped for placeholders, marked compacted + h.eq(child.lua_get("_G.placeholder_file"), messages[3].content) + h.is_true(messages[3]._meta.context_management.compacted) + h.eq(child.lua_get("_G.placeholder_buffer"), messages[4].content) + h.is_true(messages[4]._meta.context_management.compacted) + + -- Summary appended at the end with the right tag + local summary = messages[5] + h.eq(child.lua_get("_G.compact_summary_tag"), summary._meta.tag) + h.eq("user", summary.role) + h.expect_match(summary.content, "Mock summary content") + + h.eq(false, child.lua_get("_G.chat._compacting")) +end + +T["Compaction"]["re-run drops the stale summary, keeps compacted placeholders, and resummarises"] = function() + child.lua([==[ + package.loaded["codecompanion.interactions.background"] = { + new = function() + return { + ask = function(_, _, opts) + opts.on_done({ output = { content = "Second summary" } }) + end, + } + end, + } + + local Compaction = require("codecompanion.interactions.chat.context_management.compaction") + local tags = require("codecompanion.interactions.shared.tags") + local big_chunk = string.rep("payload ", 3000) + + _G.chat = { + adapter = { type = "http", name = "fake" }, + buffer_context = {}, + cycle = 9, + opts = {}, + ui = { render = function(self) return self end }, + messages = { + -- System prompt passes through + { + role = "system", + content = "system prompt", + opts = { visible = false }, + _meta = { cycle = 1, tag = tags.SYSTEM_PROMPT_FROM_CONFIG }, + }, + -- Previous compaction's placeholder passes through + { + role = "user", + content = Compaction.PLACEHOLDERS.file, + opts = { visible = true }, + _meta = { cycle = 1, tag = tags.FILE, context_management = { compacted = true } }, + }, + -- Stale summary dropped, replaced by new one + { + role = "user", + content = "OLD SUMMARY", + opts = { visible = true }, + _meta = { cycle = 1, tag = tags.COMPACT_SUMMARY }, + }, + -- New user prompt dropped and summarised + { + role = "user", + content = big_chunk, + opts = { visible = true }, + _meta = { cycle = 1 }, + }, + -- New LLM reply dropped and summarised + { + role = "llm", + content = big_chunk, + opts = { visible = true }, + _meta = { cycle = 1 }, + }, + }, + } + + Compaction.compact(_G.chat) + + _G.placeholder_file = Compaction.PLACEHOLDERS.file + _G.compact_summary_tag = tags.COMPACT_SUMMARY + ]==]) + + local messages = child.lua_get("_G.chat.messages") + + -- system + retained placeholder + new summary + h.eq(3, #messages) + h.eq("system prompt", messages[1].content) + h.eq(child.lua_get("_G.placeholder_file"), messages[2].content) + h.is_true(messages[2]._meta.context_management.compacted) + h.eq(child.lua_get("_G.compact_summary_tag"), messages[3]._meta.tag) + h.expect_match(messages[3].content, "Second summary") +end + +T["Compaction"]["skips when estimated savings fall below min_token_savings"] = function() + child.lua([==[ + _G.ask_called = false + package.loaded["codecompanion.interactions.background"] = { + new = function() + return { + ask = function() _G.ask_called = true end, + } + end, + } + + local Compaction = require("codecompanion.interactions.chat.context_management.compaction") + + _G.chat = { + adapter = { type = "http", name = "fake" }, + buffer_context = {}, + cycle = 1, + opts = {}, + ui = { render = function(self) return self end }, + messages = { + { + role = "user", + content = "hi", + opts = { visible = true }, + _meta = { cycle = 1 }, + }, + { + role = "llm", + content = "hello", + opts = { visible = true }, + _meta = { cycle = 1 }, + }, + }, + } + + _G.before = vim.deepcopy(_G.chat.messages) + Compaction.compact(_G.chat) + ]==]) + + -- Threshold short-circuits the call, Background.ask never reached + h.eq(false, child.lua_get("_G.ask_called")) + h.eq(child.lua_get("_G.before"), child.lua_get("_G.chat.messages")) +end + +T["Compaction"]["respects a min_token_savings override"] = function() + child.lua([==[ + _G.ask_called = false + package.loaded["codecompanion.interactions.background"] = { + new = function() + return { + ask = function(_, _, opts) + _G.ask_called = true + opts.on_done({ output = { content = "summary" } }) + end, + } + end, + } + + local Compaction = require("codecompanion.interactions.chat.context_management.compaction") + local tags = require("codecompanion.interactions.shared.tags") + + _G.chat = { + adapter = { type = "http", name = "fake" }, + buffer_context = {}, + cycle = 1, + opts = {}, + ui = { render = function(self) return self end }, + messages = { + { + role = "user", + content = "a small chat", + opts = { visible = true }, + _meta = { cycle = 1 }, + }, + { + role = "llm", + content = "a small response", + opts = { visible = true }, + _meta = { cycle = 1 }, + }, + }, + } + + -- min_token_savings of 1 bypasses the default threshold + Compaction.compact(_G.chat, { min_token_savings = 1 }) + + _G.compact_summary_tag = tags.COMPACT_SUMMARY + ]==]) + + h.is_true(child.lua_get("_G.ask_called")) + local messages = child.lua_get("_G.chat.messages") + h.eq(child.lua_get("_G.compact_summary_tag"), messages[#messages]._meta.tag) +end + +T["Compaction"]["leaves messages untouched when the LLM call errors"] = function() + child.lua([==[ + package.loaded["codecompanion.interactions.background"] = { + new = function() + return { + ask = function(_, _, opts) + opts.on_error("boom") + end, + } + end, + } + + local Compaction = require("codecompanion.interactions.chat.context_management.compaction") + local big_chunk = string.rep("payload ", 3000) + + _G.chat = { + adapter = { type = "http", name = "fake" }, + buffer_context = {}, + cycle = 1, + opts = {}, + ui = { render = function(self) return self end }, + messages = { + { + role = "user", + content = big_chunk, + opts = { visible = true }, + _meta = { cycle = 1 }, + }, + { + role = "llm", + content = big_chunk, + opts = { visible = true }, + _meta = { cycle = 1 }, + }, + }, + } + + _G.before = vim.deepcopy(_G.chat.messages) + Compaction.compact(_G.chat) + ]==]) + + h.eq(child.lua_get("_G.before"), child.lua_get("_G.chat.messages")) + h.eq(false, child.lua_get("_G.chat._compacting")) +end + +T["Compaction"]["fallback_to_chat_adapter retries on the chat adapter"] = function() + child.lua([==[ + -- First ask errors on the primary adapter, second succeeds on the chat adapter fallback + _G.call_count = 0 + _G.adapters_used = {} + package.loaded["codecompanion.interactions.background"] = { + new = function(args) + return { + ask = function(_, _, opts) + _G.call_count = _G.call_count + 1 + table.insert(_G.adapters_used, args.adapter) + if _G.call_count == 1 then + opts.on_error("primary failure") + else + opts.on_done({ output = { content = "fallback summary" } }) + end + end, + } + end, + } + + local Compaction = require("codecompanion.interactions.chat.context_management.compaction") + local big_chunk = string.rep("payload ", 3000) + + _G.chat_adapter = { type = "http", name = "chat" } + _G.override_adapter = { type = "http", name = "override" } + + _G.chat = { + adapter = _G.chat_adapter, + buffer_context = {}, + cycle = 1, + opts = {}, + ui = { render = function(self) return self end }, + messages = { + { + role = "user", + content = big_chunk, + opts = { visible = true }, + _meta = { cycle = 1 }, + }, + { + role = "llm", + content = big_chunk, + opts = { visible = true }, + _meta = { cycle = 1 }, + }, + }, + } + + Compaction.compact(_G.chat, { + adapter = _G.override_adapter, + fallback_to_chat_adapter = true, + }) + ]==]) + + h.eq(2, child.lua_get("_G.call_count")) + h.eq(child.lua_get("_G.override_adapter"), child.lua_get("_G.adapters_used[1]")) + h.eq(child.lua_get("_G.chat_adapter"), child.lua_get("_G.adapters_used[2]")) + + local messages = child.lua_get("_G.chat.messages") + h.expect_match(messages[#messages].content, "fallback summary") +end + +T["Compaction"]["lock prevents concurrent runs"] = function() + child.lua([==[ + _G.ask_called = false + package.loaded["codecompanion.interactions.background"] = { + new = function() + return { + ask = function() _G.ask_called = true end, + } + end, + } + + local Compaction = require("codecompanion.interactions.chat.context_management.compaction") + local big_chunk = string.rep("payload ", 3000) + + _G.chat = { + adapter = { type = "http", name = "fake" }, + buffer_context = {}, + cycle = 1, + opts = {}, + ui = { render = function(self) return self end }, + -- A compaction is already in flight + _compacting = true, + messages = { + { + role = "user", + content = big_chunk, + opts = { visible = true }, + _meta = { cycle = 1 }, + }, + { + role = "llm", + content = big_chunk, + opts = { visible = true }, + _meta = { cycle = 1 }, + }, + }, + } + + Compaction.compact(_G.chat) + ]==]) + + h.eq(false, child.lua_get("_G.ask_called")) + h.is_true(child.lua_get("_G.chat._compacting")) +end + +return T 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 diff --git a/tests/interactions/chat/slash_commands/test_compact.lua b/tests/interactions/chat/slash_commands/test_compact.lua deleted file mode 100644 index 1599ec384..000000000 --- a/tests/interactions/chat/slash_commands/test_compact.lua +++ /dev/null @@ -1,73 +0,0 @@ -local config = require("tests.config") -local h = require("tests.helpers") - -local child = MiniTest.new_child_neovim() -local new_set = MiniTest.new_set - -T = new_set({ - hooks = { - pre_case = function() - h.child_start(child) - - child.lua([[ - h = require('tests.helpers') - local tags = require('codecompanion.interactions.shared.tags') - _G.chat, _ = h.setup_chat_buffer() - - _G.chat.messages = { - { role = "system", content = "You are a helpful assistant." }, - { role = "user", content = "FILE", _meta = { tag = tags.FILE } }, - { role = "user", content = "BUFFER", _meta = { tag = tags.EDITOR_CONTEXT } }, - { role = "user", content = "Hello!" }, - { role = "assistant", content = "Hi there! How can I assist you today?" }, - { role = "user", content = "Can you help me with Lua?" }, - { role = "llm", content = "Sure! What do you need help with?" }, - } - - _G.compact = require("codecompanion.interactions.chat.slash_commands.builtin.compact").new({ - Chat = chat, - context = {}, - opts = {}, - }) - ]]) - end, - post_case = function() - child.lua([[h.teardown_chat_buffer()]]) - end, - post_once = child.stop, - }, -}) - -T["Compact"] = new_set() - -T["Compact"]["Creates conversations from a chat buffer"] = function() - local result = child.lua([[ - return _G.compact:create_conversation(_G.chat.messages) - ]]) - - h.eq( - table.concat({ - 'FILE', - 'BUFFER', - 'Hello!', - 'Hi there! How can I assist you today?', - 'Can you help me with Lua?', - }), - result - ) -end - -T["Compact"]["compacts chat messages"] = function() - local result = child.lua([[ - _G.compact:compact_messages() - return _G.chat.messages - ]]) - - local messages = vim.tbl_map(function(msg) - return msg.content - end, result) - - h.eq({ "You are a helpful assistant.", "FILE", "BUFFER" }, messages) -end - -return T