olimorris / olimorris/codecompanion.nvim

[Bug]: /fork renders the Context block twice in the forked chat

Open
#3,386 6 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug reviewed-by-AI
Dominant language
Lua
Stars
6.9k
Forks
457
Avg merge
1d 9h
Merged PRs (30d)
46

Description

Pre-submission checklist
  • I have read the documentation
  • I have updated the plugin and all of its dependencies to the latest versions
  • I have searched for existing issues and discussions
  • If ACP related, I have confirmed that the issue is a CodeCompanion one and not an issue in the adapter itself
  • My issue is not a minor or cosmetic quirk (e.g. formatting, spacing, or other non-functional details)
  • I have reproduced this from a clean minimal.lua and included everything needed for someone else to hit the same bug
Environment

Neovim: 0.12.5
OS: Ubuntu 20.04.6 LTS
CodeCompanion: main @ 070285d5 (fresh .repro clone)
Adapter and model: copilot - no request is ever sent, the bug happens while the fork is being built

What happened, and what did you expect?

Forking a chat with /fork renders the > Context: block twice in the forked buffer, once from the source chat's context and once from the context the new chat loads for itself:

## Me

> Context:
> - <group>demo</group>

> Context:
> - <group>demo</group>

I expected one block.

SlashCommand:output (slash_commands/builtin/fork.lua) builds the fork with Chat.new, which runs load_tools (interactions/chat/init.lua:507) and adds config.interactions.chat.tools.opts.default_tools. Each tool_registry:add calls context:add, which writes its row into the buffer and adds the tool's system prompt message - that is the second block. The fork then discards that bookkeeping: forked.context_items = context_items (fork.lua:67-69) overwrites the freshly loaded items with a copy of the source's, tool_registry.groups/in_use/schemas are overwritten likewise, and forked.context:render() inserts a whole new block at header_line + 1 - the first block. stop_context_insertion = true is already passed, but that only suppresses the visual selection, not the default tools.

Two consequences beyond the cosmetic duplication:

  1. The payload carries each default tool or group system prompt twice. In the repro below, the demo group's system prompt is in chat.messages twice - once from the copied history, once from the fresh load. Chat:add_message does not deduplicate.
  2. Revoking a duplicated context row silently does nothing. Context:get_from_chat returns the id twice, so after deleting one row Chat:check_context still finds it in the buffer and keeps the tool granted. The repro prints read_file still in use=true after deleting one of the two <group>demo</group> rows.

Which behaviour do you consider correct for a fork - inherit the source's context and load no defaults, or start from a fresh default context and copy nothing? I have a fix for either and am happy to PR it (configurable under slash_commands.fork.opts if you want both). In the fresh-context case there is a follow-up decision: the copied history still contains the source's context-linked messages (rules, tool prompts, /file output) that the new Context block no longer lists, so those would either have to be stripped or left invisible.

minimal.lua
---@diagnostic disable: missing-fields

--[[
NOTE: Set the config path to enable the copilot adapter to work.
It will search the following paths for a token:
  - "$CODECOMPANION_TOKEN_PATH/github-copilot/hosts.json"
  - "$CODECOMPANION_TOKEN_PATH/github-copilot/apps.json"
--]]
vim.env["CODECOMPANION_TOKEN_PATH"] = vim.fn.expand("~/.config")

vim.env.LAZY_STDPATH = ".repro"
load(vim.fn.system("curl -s https://raw.githubusercontent.com/folke/lazy.nvim/main/bootstrap.lua"))()

-- Your CodeCompanion setup
local plugins = {
  {
    "olimorris/codecompanion.nvim",
    dependencies = {
      { "nvim-lua/plenary.nvim" },
      {
        "nvim-treesitter/nvim-treesitter",
        lazy = false,
        build = ":TSUpdate",
      },
    },
    opts = {
      interactions = {
        chat = {
          adapter = "copilot",
          tools = {
            groups = {
              -- A group with a system prompt, so the duplication is visible in
              -- the message history as well as in the chat buffer
              ["demo"] = {
                description = "Demo group",
                system_prompt = "Demo group system prompt",
                tools = { "read_file" },
              },
            },
            opts = {
              -- Any context a fresh chat loads by itself is enough to show the bug
              default_tools = { "demo" },
            },
          },
        },
        inline = { adapter = "copilot" },
      },
      opts = {
        log_level = "DEBUG",
      },
    },
  },
}

require("lazy.minit").repro({ spec = plugins })

-- CONFIGURE PLUGINS HERE -----------------------------------------------------

-- Setup Tree-sitter
-- NOTE: Please restart Neovim to ensure parsers are loaded correctly
require("nvim-treesitter")
  .install({
    "lua",
    "markdown",
    "markdown_inline",
    "yaml",
  }, { summary = true, max_jobs = 10 })
  :wait(1800000)
Steps to reproduce
  1. nvim -u minimal.lua, quit once the Tree-sitter parsers have installed, then start it again (as the file notes)
  2. Open a chat with :CodeCompanionChat. It shows one > Context: block containing > - <group>demo</group>
  3. Type /fork in the chat buffer and accept it from the completion menu (<C-x><C-o> works with this minimal config)
  4. Press Enter at the Fork Title prompt to accept the default title
  5. The forked chat buffer shows two identical > Context: blocks. No message has been sent to an LLM at any point
  6. Delete one of the two > - <group>demo</group> rows to revoke the group. The group stays granted, because the id is still present in the other block

Deterministic alternative, which is how I reproduced it: save the drive.lua below next to minimal.lua and run

nvim --headless -u minimal.lua -c "luafile drive.lua" -c "qa!"

which prints

source: context blocks=1 group system prompts=1
--- forked buffer ---
## Me
> Context:
> - <group>demo</group>
> Context:
> - <group>demo</group>
--- context blocks: 2 (expected 1) ---
forked: ids in buffer={ "<group>demo</group>", "<group>demo</group>" }
forked: group system prompts=2 (expected 1)
after deleting one row: read_file still in use=true
Example files

No source file is involved in this bug. The file below is the headless driver referenced above.

drive.lua

-- Headless driver: answers the fork title prompt, then reports the forked chat's state.
vim.ui.input = function(_, on_confirm)
  on_confirm("Fork of chat")
end

local Chat = require("codecompanion.interactions.chat")
local config = require("codecompanion.config")

local function count_lines_matching(bufnr, pattern)
  local count = 0
  for _, line in ipairs(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)) do
    if line:match(pattern) then
      count = count + 1
    end
  end
  return count
end

local function count_group_prompts(chat)
  local count = 0
  for _, message in ipairs(chat.messages) do
    if message.content == "Demo group system prompt" then
      count = count + 1
    end
  end
  return count
end

vim.cmd("CodeCompanionChat")
vim.wait(3000, function()
  return Chat.last_chat() ~= nil
end)

local source = Chat.last_chat()
print(
  "source: context blocks="
    .. count_lines_matching(source.bufnr, "^> Context:")
    .. " group system prompts="
    .. count_group_prompts(source)
)

require("codecompanion.interactions.chat.slash_commands.builtin.fork")
  .new({
    Chat = source,
    config = config.interactions.chat.slash_commands["fork"],
    context = source.buffer_context,
  })
  :execute()

vim.wait(3000)

local forked = Chat.last_chat()

print("--- forked buffer ---")
print(table.concat(vim.api.nvim_buf_get_lines(forked.bufnr, 0, -1, false), "\n"))
print("--- context blocks: " .. count_lines_matching(forked.bufnr, "^> Context:") .. " (expected 1) ---")
print("forked: ids in buffer=" .. vim.inspect(forked.context:get_from_chat()))
print("forked: group system prompts=" .. count_group_prompts(forked) .. " (expected 1)")

-- Now delete one context row, as a user revoking the group would
forked.ui:unlock_buf()
for i, line in ipairs(vim.api.nvim_buf_get_lines(forked.bufnr, 0, -1, false)) do
  if line == "> - <group>demo</group>" then
    vim.api.nvim_buf_set_lines(forked.bufnr, i - 1, i, false, {})
    break
  end
end
forked:check_context()
print("after deleting one row: read_file still in use=" .. tostring(forked.tool_registry.in_use["read_file"] == true))
Log output (optional)

No response

Screenshots or recordings (optional)
Image

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with slash_commands/builtin/fork.lua, interactions/chat/init.lua around load_tools, and the Context and tool_registry methods described in the report. Run the supplied minimal.lua and drive.lua reproduction to inspect the duplicated buffer rows and system prompts. Done means the chosen fork semantics produce one context block and prompt per item, with revoking a row correctly removing the tool.

Written by the indexing model from the issue text.

Assessment

Tech stack
lua
Domain
tooling
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.