Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions doc/codecompanion-history.txt
Original file line number Diff line number Diff line change
@@ -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*
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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*


Expand Down Expand Up @@ -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

<


Expand Down
1 change: 1 addition & 0 deletions lua/codecompanion/_extensions/history/storage.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lua/codecompanion/_extensions/history/title_generator.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lua/codecompanion/_extensions/history/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions lua/codecompanion/_extensions/history/ui.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand All @@ -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
Expand Down
234 changes: 234 additions & 0 deletions tests/test_acp_session.lua
Original file line number Diff line number Diff line change
@@ -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