Skip to content

Commit cdd623d

Browse files
authored
fix(acp): cancel prompt and tools (#3024)
Closes #3011
1 parent 9f0779b commit cdd623d

6 files changed

Lines changed: 52 additions & 10 deletions

File tree

lua/codecompanion/acp/init.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -639,7 +639,7 @@ function Connection:handle_rpc_message(line)
639639
if message.id and not message.method then
640640
self:store_rpc_response(message)
641641
if message.result and message.result ~= vim.NIL and message.result.stopReason then
642-
if self._active_prompt and self._active_prompt.handle_done then
642+
if self._active_prompt and self._active_prompt._request_id == message.id and self._active_prompt.handle_done then
643643
self._active_prompt:handle_done(message.result.stopReason)
644644
end
645645
end

lua/codecompanion/acp/prompt_builder.lua

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ function PromptBuilder:on_error(fn)
7070
self.handlers.error = fn
7171
return self
7272
end
73+
function PromptBuilder:on_cancel(fn)
74+
self.handlers.cancel = fn
75+
return self
76+
end
7377
function PromptBuilder:with_options(opts)
7478
self.options = vim.tbl_extend("force", self.options, opts or {})
7579
return self
@@ -105,6 +109,7 @@ function PromptBuilder:send()
105109
-- Send the prompt
106110
local jsonrpc = require("codecompanion.utils.jsonrpc")
107111
local id = self.connection._state.id_gen:next()
112+
self._request_id = id
108113
local req = jsonrpc.request(id, self.connection.METHODS.SESSION_PROMPT, {
109114
sessionId = self.connection.session_id,
110115
prompt = self.messages,
@@ -213,9 +218,9 @@ function PromptBuilder:handle_permission_request(id, params)
213218
session_id = params.sessionId,
214219
tool_call = tool_call,
215220
options = options,
216-
respond = function(option_id, canceled)
217-
if canceled or not option_id then
218-
respond({ outcome = "canceled" })
221+
respond = function(option_id, cancelled)
222+
if cancelled or not option_id then
223+
respond({ outcome = "cancelled" })
219224
else
220225
respond({ outcome = "selected", optionId = option_id })
221226
end
@@ -297,6 +302,13 @@ function PromptBuilder:cancel()
297302
utils.fire("RequestFinished", self.options)
298303
end
299304
end
305+
306+
-- Handler MUST respond to all requests with "cancelled"
307+
-- Ref: https://agentclientprotocol.com/protocol/prompt-turn#cancellation
308+
if self.handlers.cancel then
309+
pcall(self.handlers.cancel)
310+
end
311+
300312
self.connection._active_prompt = nil
301313
end
302314

lua/codecompanion/interactions/chat/acp/handler.lua

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ local watch = require("codecompanion.interactions.shared.watch")
1212
---@field reasoning table Reasoning output from the Agent
1313
---@field tools table<string, table> Cache of tool calls by their ID
1414
---@field ui_state table<string, table> Cache of tool call UI states (line_number, icon_id) by tool call ID
15-
---@field _permission { queue: CodeCompanion.Queue, active: boolean } Internal state for managing permission requests
15+
---@field _permission { queue: CodeCompanion.Queue, active: boolean, respond: function|nil } Internal state for managing permission requests
1616
local ACPHandler = {}
1717

1818
---@param chat CodeCompanion.Chat
@@ -27,6 +27,7 @@ function ACPHandler.new(chat)
2727
_permission = {
2828
active = false,
2929
queue = Queue.new(),
30+
respond = nil,
3031
},
3132
}, { __index = ACPHandler })
3233

@@ -196,6 +197,9 @@ function ACPHandler:create_and_send_prompt(payload)
196197
:on_error(function(error)
197198
self:handle_error(error)
198199
end)
200+
:on_cancel(function()
201+
self:_clear_permission_queue()
202+
end)
199203
:with_options({ bufnr = self.chat.bufnr, interaction = "chat" })
200204
:send()
201205
end
@@ -330,11 +334,18 @@ function ACPHandler:_process_next_permission()
330334
end, request.options or {}))
331335
)
332336

337+
-- The original respond function is stored so that if the user cancels the request, we can respond as per the spec
338+
self._permission.respond = request.respond
339+
333340
-- Ensure that the next item in the queue is processed after the user's response
334341
local send_response = request.respond
335-
request.respond = function(option_id, canceled)
336-
send_response(option_id, canceled)
342+
request.respond = function(option_id, cancelled)
343+
if not self._permission.respond then
344+
return
345+
end
346+
send_response(option_id, cancelled)
337347
self._permission.active = false
348+
self._permission.respond = nil
338349
self:_process_next_permission()
339350
end
340351

@@ -344,11 +355,24 @@ end
344355
---Clear any requests in the queue
345356
---@return nil
346357
function ACPHandler:_clear_permission_queue()
358+
local had_pending = self._permission.respond ~= nil or not self._permission.queue:is_empty()
359+
360+
-- Cancel the currently active permission request (if any)
361+
if self._permission.respond then
362+
pcall(self._permission.respond, nil, true)
363+
self._permission.respond = nil
364+
end
365+
366+
-- Cancel all queued permission requests
347367
while not self._permission.queue:is_empty() do
348368
local request = self._permission.queue:pop()
349369
pcall(request.respond, nil, true)
350370
end
351371
self._permission.active = false
372+
373+
if had_pending then
374+
utils.fire("ToolApprovalFinished", { bufnr = self.chat.bufnr, choice = "cancelled" })
375+
end
352376
end
353377

354378
---Handle the prompt response when it's complete

tests/acp/test_acp.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,7 @@ T["ACP Responses"]["_handle_done when stopReason present"] = function()
405405
local connection = create_test_connection()
406406
local seen
407407
connection._active_prompt = {
408+
_request_id = 1,
408409
handle_done = function(_, sr) seen = sr end
409410
}
410411
connection:handle_rpc_message('{"jsonrpc":"2.0","id":1,"result":{"stopReason":"end_turn"}}')

tests/acp/test_prompt_builder.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ T["Prompt Builder"]["Auto-cancels when no handler is registered"] = function()
306306
]])
307307

308308
h.eq(13, result.id)
309-
h.eq("canceled", result.outcome)
309+
h.eq("cancelled", result.outcome)
310310
h.eq(false, result.has_optionId)
311311
end
312312

tests/interactions/chat/acp/test_handler.lua

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ T = new_set({
6969
return self
7070
end,
7171
72+
on_cancel = function(self, handler)
73+
self.handlers.cancel = handler
74+
return self
75+
end,
76+
7277
with_options = function(self, opts)
7378
self.options = opts
7479
return self
@@ -846,7 +851,7 @@ T["ACPHandler"]["Permission Queue"]["clears queue on completion"] = function()
846851

847852
h.is_true(result.queue_empty)
848853
h.is_false(result.active)
849-
h.eq({ "tool_2", "tool_3" }, result.rejected)
854+
h.eq({ "tool_1", "tool_2", "tool_3" }, result.rejected)
850855
end
851856

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

905910
h.is_true(result.queue_empty)
906911
h.is_false(result.active)
907-
h.eq({ "tool_2" }, result.rejected)
912+
h.eq({ "tool_1", "tool_2" }, result.rejected)
908913
end
909914

910915
T["ACPHandler"]["Config Options"] = new_set()

0 commit comments

Comments
 (0)