Skip to content
Merged
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
2 changes: 1 addition & 1 deletion lua/codecompanion/acp/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -639,7 +639,7 @@ function Connection:handle_rpc_message(line)
if message.id and not message.method then
self:store_rpc_response(message)
if message.result and message.result ~= vim.NIL and message.result.stopReason then
if self._active_prompt and self._active_prompt.handle_done then
if self._active_prompt and self._active_prompt._request_id == message.id and self._active_prompt.handle_done then
self._active_prompt:handle_done(message.result.stopReason)
end
end
Expand Down
18 changes: 15 additions & 3 deletions lua/codecompanion/acp/prompt_builder.lua
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ function PromptBuilder:on_error(fn)
self.handlers.error = fn
return self
end
function PromptBuilder:on_cancel(fn)
self.handlers.cancel = fn
return self
end
function PromptBuilder:with_options(opts)
self.options = vim.tbl_extend("force", self.options, opts or {})
return self
Expand Down Expand Up @@ -105,6 +109,7 @@ function PromptBuilder:send()
-- Send the prompt
local jsonrpc = require("codecompanion.utils.jsonrpc")
local id = self.connection._state.id_gen:next()
self._request_id = id
local req = jsonrpc.request(id, self.connection.METHODS.SESSION_PROMPT, {
sessionId = self.connection.session_id,
prompt = self.messages,
Expand Down Expand Up @@ -213,9 +218,9 @@ function PromptBuilder:handle_permission_request(id, params)
session_id = params.sessionId,
tool_call = tool_call,
options = options,
respond = function(option_id, canceled)
if canceled or not option_id then
respond({ outcome = "canceled" })
respond = function(option_id, cancelled)
if cancelled or not option_id then
respond({ outcome = "cancelled" })
else
respond({ outcome = "selected", optionId = option_id })
end
Expand Down Expand Up @@ -297,6 +302,13 @@ function PromptBuilder:cancel()
utils.fire("RequestFinished", self.options)
end
end

-- Handler MUST respond to all requests with "cancelled"
-- Ref: https://agentclientprotocol.com/protocol/prompt-turn#cancellation
if self.handlers.cancel then
pcall(self.handlers.cancel)
end

self.connection._active_prompt = nil
end

Expand Down
30 changes: 27 additions & 3 deletions lua/codecompanion/interactions/chat/acp/handler.lua
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ local watch = require("codecompanion.interactions.shared.watch")
---@field reasoning table Reasoning output from the Agent
---@field tools table<string, table> Cache of tool calls by their ID
---@field ui_state table<string, table> Cache of tool call UI states (line_number, icon_id) by tool call ID
---@field _permission { queue: CodeCompanion.Queue, active: boolean } Internal state for managing permission requests
---@field _permission { queue: CodeCompanion.Queue, active: boolean, respond: function|nil } Internal state for managing permission requests
local ACPHandler = {}

---@param chat CodeCompanion.Chat
Expand All @@ -27,6 +27,7 @@ function ACPHandler.new(chat)
_permission = {
active = false,
queue = Queue.new(),
respond = nil,
},
}, { __index = ACPHandler })

Expand Down Expand Up @@ -196,6 +197,9 @@ function ACPHandler:create_and_send_prompt(payload)
:on_error(function(error)
self:handle_error(error)
end)
:on_cancel(function()
self:_clear_permission_queue()
end)
:with_options({ bufnr = self.chat.bufnr, interaction = "chat" })
:send()
end
Expand Down Expand Up @@ -330,11 +334,18 @@ function ACPHandler:_process_next_permission()
end, request.options or {}))
)

-- The original respond function is stored so that if the user cancels the request, we can respond as per the spec
self._permission.respond = request.respond

-- Ensure that the next item in the queue is processed after the user's response
local send_response = request.respond
request.respond = function(option_id, canceled)
send_response(option_id, canceled)
request.respond = function(option_id, cancelled)
if not self._permission.respond then
return
end
send_response(option_id, cancelled)
self._permission.active = false
self._permission.respond = nil
self:_process_next_permission()
end

Expand All @@ -344,11 +355,24 @@ end
---Clear any requests in the queue
---@return nil
function ACPHandler:_clear_permission_queue()
local had_pending = self._permission.respond ~= nil or not self._permission.queue:is_empty()

-- Cancel the currently active permission request (if any)
if self._permission.respond then
pcall(self._permission.respond, nil, true)
self._permission.respond = nil
end

-- Cancel all queued permission requests
while not self._permission.queue:is_empty() do
local request = self._permission.queue:pop()
pcall(request.respond, nil, true)
end
self._permission.active = false

if had_pending then
utils.fire("ToolApprovalFinished", { bufnr = self.chat.bufnr, choice = "cancelled" })
end
end

---Handle the prompt response when it's complete
Expand Down
1 change: 1 addition & 0 deletions tests/acp/test_acp.lua
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,7 @@ T["ACP Responses"]["_handle_done when stopReason present"] = function()
local connection = create_test_connection()
local seen
connection._active_prompt = {
_request_id = 1,
handle_done = function(_, sr) seen = sr end
}
connection:handle_rpc_message('{"jsonrpc":"2.0","id":1,"result":{"stopReason":"end_turn"}}')
Expand Down
2 changes: 1 addition & 1 deletion tests/acp/test_prompt_builder.lua
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ T["Prompt Builder"]["Auto-cancels when no handler is registered"] = function()
]])

h.eq(13, result.id)
h.eq("canceled", result.outcome)
h.eq("cancelled", result.outcome)
h.eq(false, result.has_optionId)
end

Expand Down
9 changes: 7 additions & 2 deletions tests/interactions/chat/acp/test_handler.lua
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ T = new_set({
return self
end,

on_cancel = function(self, handler)
self.handlers.cancel = handler
return self
end,

with_options = function(self, opts)
self.options = opts
return self
Expand Down Expand Up @@ -846,7 +851,7 @@ T["ACPHandler"]["Permission Queue"]["clears queue on completion"] = function()

h.is_true(result.queue_empty)
h.is_false(result.active)
h.eq({ "tool_2", "tool_3" }, result.rejected)
h.eq({ "tool_1", "tool_2", "tool_3" }, result.rejected)
end

T["ACPHandler"]["Permission Queue"]["clears queue on error"] = function()
Expand Down Expand Up @@ -904,7 +909,7 @@ T["ACPHandler"]["Permission Queue"]["clears queue on error"] = function()

h.is_true(result.queue_empty)
h.is_false(result.active)
h.eq({ "tool_2" }, result.rejected)
h.eq({ "tool_1", "tool_2" }, result.rejected)
end

T["ACPHandler"]["Config Options"] = new_set()
Expand Down
Loading