diff --git a/doc/codecompanion-history.txt b/doc/codecompanion-history.txt index 2836d08..bc6f3b0 100644 --- a/doc/codecompanion-history.txt +++ b/doc/codecompanion-history.txt @@ -1,4 +1,4 @@ -*codecompanion-history.txt* For NVIM v0.8.0 Last change: 2025 July 05 +*codecompanion-history.txt* For NVIM v0.8.0 Last change: 2026 February 25 ============================================================================== Table of Contents *codecompanion-history-table-of-contents* @@ -188,10 +188,13 @@ ADD HISTORY EXTENSION TO CODECOMPANION CONFIG ~ return original_title end }, - + ---On exiting and entering neovim, loads the last chat on opening chat continue_last_chat = false, + ---When chat is cleared with `gx` delete the chat from history delete_on_clearing_chat = false, + ---Directory path to save the chats dir_to_save = vim.fn.stdpath("data") .. "/codecompanion-history", + ---Enable detailed logging for history extension enable_logging = false, -- Summary system @@ -236,6 +239,10 @@ ADD HISTORY EXTENSION TO CODECOMPANION CONFIG ~ < + [!WARNING] Title and summary generation defaults to current chat’s adapter + and model. Make sure to set cheaper models in `title_generation_opts` and + `summary.generation_opts` to avoid using premium models. + 🛠️ USAGE*codecompanion-history-codecompanion-history-extension-🛠️-usage* @@ -427,11 +434,14 @@ Example usage: -- Duplicate a chat with auto-generated title (appends "(1)") local new_save_id = history.duplicate_chat("some_save_id") + -- Summary operations history.generate_summary() -- generates for current chat + local summaries = history.get_summaries() + local summary_content = history.load_summary("some_save_id") - history.preview_summary() -- opens summary for editing + < diff --git a/lua/codecompanion/_extensions/history/storage.lua b/lua/codecompanion/_extensions/history/storage.lua index cf32ac2..66768b7 100644 --- a/lua/codecompanion/_extensions/history/storage.lua +++ b/lua/codecompanion/_extensions/history/storage.lua @@ -288,6 +288,7 @@ function Storage:save_chat(chat) title_refresh_count = chat.opts.title_refresh_count or 0, cwd = cwd, project_root = utils.find_project_root(cwd), + acp_session_id = chat.acp_connection and chat.acp_connection.session_id or nil, } -- Save chat to file diff --git a/lua/codecompanion/_extensions/history/title_generator.lua b/lua/codecompanion/_extensions/history/title_generator.lua index e88afe0..36cf7e4 100644 --- a/lua/codecompanion/_extensions/history/title_generator.lua +++ b/lua/codecompanion/_extensions/history/title_generator.lua @@ -259,7 +259,7 @@ function TitleGenerator:_make_adapter_request(chat, prompt, callback) if _adapter.handlers.chat_output then result = _adapter.handlers.chat_output(_adapter, data) else - result = adapters.call_handler(_adapter, "parse_chat", data) + result = adapters.call_handler(_adapter, "parse_chat", data) end if result and result.status then if result.status == CONSTANTS.STATUS_SUCCESS then diff --git a/lua/codecompanion/_extensions/history/types.lua b/lua/codecompanion/_extensions/history/types.lua index 21a322e..997f7b9 100644 --- a/lua/codecompanion/_extensions/history/types.lua +++ b/lua/codecompanion/_extensions/history/types.lua @@ -71,6 +71,7 @@ ---@field title_refresh_count? number ---@field cwd string Current working directory when chat was saved ---@field project_root string Project root directory when chat was saved +---@field acp_session_id? string ACP session ID for restoring server-side state ---@class CodeCompanion.History.ChatIndexData ---@field title string diff --git a/lua/codecompanion/_extensions/history/ui.lua b/lua/codecompanion/_extensions/history/ui.lua index daf08bb..741140e 100644 --- a/lua/codecompanion/_extensions/history/ui.lua +++ b/lua/codecompanion/_extensions/history/ui.lua @@ -587,10 +587,43 @@ function UI:create_chat(chat_data) settings = settings, adapter = adapter --[[@as CodeCompanion.Adapter]], title = title, + acp_session_id = chat_data.acp_session_id, --INFO: No need to ignore system prompt here, thanks to oli we don't add system messages with same tag (`from_config`) twice. -- This also fixes `gx` removing the system prompt from the chat if we pass `ignore_system_prompt = true` -- ignore_system_prompt = true, }) --[[@as CodeCompanion.History.Chat]] + if chat_data.acp_session_id then + log:trace("Restoring ACP session: %s", chat_data.acp_session_id) + local ACP = require("codecompanion.acp") + if chat.acp_connection then + chat.acp_connection:disconnect() + end + -- not directly available from the chat instance to pass the session id, + -- but if the connection has a session id we can restore + chat.acp_connection = ACP.new({ + adapter = chat.adapter, + session_id = chat_data.acp_session_id, + }) + + local connected = chat.acp_connection:connect_and_initialize() + if connected and chat.acp_connection.session_id then + require("codecompanion.interactions.chat.acp.commands").link_buffer_to_session( + chat.bufnr, + chat.acp_connection.session_id + ) + chat:update_metadata() + elseif + connected + and chat.acp_connection ~= nil + and chat.acp_connection.session_id ~= chat_data.acp_session_id + then + log:warn("ACP session not fully restored, session id mismatch.") + else + log:warn("Failed to restore ACP session, a new session will be created.") + chat.acp_connection = nil + end + end + -- Handle both old (refs) and new (context_items) storage formats local stored_context_items = chat_data.context_items or chat_data.refs or {} local chat_context_items = chat.context_items or {} @@ -607,7 +640,9 @@ function UI:create_chat(chat_data) chat.tool_registry.in_use = chat_data.in_use or {} chat.cycle = chat_data.cycle or 1 chat.opts.title_refresh_count = chat_data.title_refresh_count or 0 + log:trace("Successfully created chat with save_id: %s", save_id or "N/A") + return chat end local adapter = chat_data.adapter diff --git a/tests/test_acp_session.lua b/tests/test_acp_session.lua new file mode 100644 index 0000000..93ae119 --- /dev/null +++ b/tests/test_acp_session.lua @@ -0,0 +1,234 @@ +---@brief [[ +--- ACP Session ID Persistence Tests +--- +--- This test suite verifies the functionality of ACP session ID save/restore +--- in the CodeCompanion history extension. It tests: +--- +--- 1. Save Operations: +--- - Capturing acp_session_id from ACP connection +--- - Handling chats without ACP connection +--- +--- 2. Backward Compatibility: +--- - Loading old chats without acp_session_id +--- +--- 3. Data Integrity: +--- - acp_session_id preserved through save/load cycle +--- - acp_session_id preserved through duplication +---]] + +local h = require("tests.helpers") +local eq, new_set = MiniTest.expect.equality, MiniTest.new_set +local T = new_set() + +local child = h.new_child_neovim() + +T = new_set({ + hooks = { + pre_case = function() + child.setup() + child.lua([[ + local log = require("codecompanion._extensions.history.log") + log.setup_logging(false) + + local Storage = require("codecompanion._extensions.history.storage") + test_storage = Storage.new({ + dir_to_save = vim.fn.stdpath("data") .. "/codecompanion-history-acp-test-" .. os.time() + }) + ]]) + end, + post_case = function() + child.lua([[ + if test_storage and test_storage.base_path then + local folder = test_storage.base_path + if vim.fn.isdirectory(folder) == 1 then + vim.fn.delete(folder, "rf") + end + end + ]]) + end, + post_once = child.stop, + }, +}) + +-- Save Operations +T["ACP Session Save"] = new_set() + +T["ACP Session Save"]["captures acp_session_id when ACP connection present"] = function() + local result = child.lua([[ + local h = require("tests.helpers") + local chat_data = h.create_test_chat("test_acp_save") + + test_storage:save_chat({ + opts = { + save_id = chat_data.save_id, + title = chat_data.title, + }, + messages = chat_data.messages, + settings = chat_data.settings, + adapter = { name = "test_acp" }, + context_items = {}, + tool_registry = { schemas = {}, in_use = {} }, + cycle = 1, + acp_connection = { session_id = "acp-test-session-123" }, + }) + + local loaded = test_storage:load_chat("test_acp_save") + + return { + has_session_id = loaded and loaded.acp_session_id ~= nil, + session_id = loaded and loaded.acp_session_id, + } + ]]) + + eq(true, result.has_session_id) + eq("acp-test-session-123", result.session_id) +end + +T["ACP Session Save"]["saves nil acp_session_id when no ACP connection"] = function() + local result = child.lua([[ + local h = require("tests.helpers") + local chat_data = h.create_test_chat("test_no_acp") + + test_storage:save_chat({ + opts = { + save_id = chat_data.save_id, + title = chat_data.title, + }, + messages = chat_data.messages, + settings = chat_data.settings, + adapter = { name = "openai" }, + context_items = {}, + tool_registry = { schemas = {}, in_use = {} }, + cycle = 1, + }) + + local loaded = test_storage:load_chat("test_no_acp") + + return { + loaded_ok = loaded ~= nil, + has_session_id = loaded and loaded.acp_session_id ~= nil, + } + ]]) + + eq(true, result.loaded_ok) + eq(false, result.has_session_id) +end + +T["ACP Session Save"]["saves nil when ACP connection has no session_id"] = function() + local result = child.lua([[ + local h = require("tests.helpers") + local chat_data = h.create_test_chat("test_acp_no_sid") + + test_storage:save_chat({ + opts = { + save_id = chat_data.save_id, + title = chat_data.title, + }, + messages = chat_data.messages, + settings = chat_data.settings, + adapter = { name = "test_acp" }, + context_items = {}, + tool_registry = { schemas = {}, in_use = {} }, + cycle = 1, + acp_connection = { session_id = nil }, + }) + + local loaded = test_storage:load_chat("test_acp_no_sid") + + return { + loaded_ok = loaded ~= nil, + has_session_id = loaded and loaded.acp_session_id ~= nil, + } + ]]) + + eq(true, result.loaded_ok) + eq(false, result.has_session_id) +end + +-- Backward Compatibility +T["ACP Session Backward Compat"] = new_set() + +T["ACP Session Backward Compat"]["loads old chats without acp_session_id"] = function() + local result = child.lua([[ + local h = require("tests.helpers") + local chat_data = h.create_test_chat("test_old_format") + test_storage:_save_chat_to_file(chat_data) + test_storage:_update_index_entry(chat_data) + + local loaded = test_storage:load_chat("test_old_format") + + return { + loaded_ok = loaded ~= nil, + session_id = loaded and loaded.acp_session_id, + title = loaded and loaded.title, + } + ]]) + + eq(true, result.loaded_ok) + eq(nil, result.session_id) + eq("Test Chat test_old_format", result.title) +end + +-- Data Integrity +T["ACP Session Data Integrity"] = new_set() + +T["ACP Session Data Integrity"]["preserves acp_session_id through save/load cycle"] = function() + local result = child.lua([[ + local h = require("tests.helpers") + local chat_data = h.create_test_chat("test_roundtrip") + + test_storage:save_chat({ + opts = { + save_id = chat_data.save_id, + title = chat_data.title, + }, + messages = chat_data.messages, + settings = chat_data.settings, + adapter = { name = "claude_code" }, + context_items = {}, + tool_registry = { schemas = {}, in_use = {} }, + cycle = 3, + acp_connection = { session_id = "session-roundtrip-abc" }, + }) + + local loaded = test_storage:load_chat("test_roundtrip") + + return { + session_id = loaded and loaded.acp_session_id, + adapter = loaded and loaded.adapter, + cycle = loaded and loaded.cycle, + title = loaded and loaded.title, + } + ]]) + + eq("session-roundtrip-abc", result.session_id) + eq("claude_code", result.adapter) + eq(3, result.cycle) + eq("Test Chat test_roundtrip", result.title) +end + +T["ACP Session Data Integrity"]["preserves acp_session_id through duplication"] = function() + local result = child.lua([[ + local h = require("tests.helpers") + local chat_data = h.create_test_chat("test_dup_acp") + chat_data.acp_session_id = "session-to-duplicate" + + test_storage:_save_chat_to_file(chat_data) + test_storage:_update_index_entry(chat_data) + + local new_save_id = test_storage:duplicate_chat("test_dup_acp", "Duplicated ACP Chat") + local duplicated = test_storage:load_chat(new_save_id) + + return { + new_save_id = new_save_id, + session_id = duplicated and duplicated.acp_session_id, + title = duplicated and duplicated.title, + } + ]]) + + eq(true, result.new_save_id ~= nil) + eq("session-to-duplicate", result.session_id) + eq("Duplicated ACP Chat", result.title) +end + +return T