Skip to content

Commit 842ce54

Browse files
committed
wip
1 parent de96da4 commit 842ce54

2 files changed

Lines changed: 284 additions & 3 deletions

File tree

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

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
local Queue = require("codecompanion.utils.queue")
12
local config = require("codecompanion.config")
23
local formatter = require("codecompanion.interactions.chat.acp.formatters")
34
local log = require("codecompanion.utils.log")
@@ -10,6 +11,7 @@ local watch = require("codecompanion.interactions.shared.watch")
1011
---@field reasoning table Reasoning output from the Agent
1112
---@field tools table<string, table> Cache of tool calls by their ID
1213
---@field ui_state table<string, table> Cache of tool call UI states (line_number, icon_id) by tool call ID
14+
---@field _permission {queue: CodeCompanion.Queue, active: boolean} Internal state for managing permission requests
1315
local ACPHandler = {}
1416

1517
---@param chat CodeCompanion.Chat
@@ -21,6 +23,10 @@ function ACPHandler.new(chat)
2123
reasoning = {},
2224
tools = {},
2325
ui_state = {},
26+
_permission = {
27+
active = false,
28+
queue = Queue.new(),
29+
},
2430
}, { __index = ACPHandler })
2531

2632
return self --[[@type CodeCompanion.Chat.ACPHandler]]
@@ -280,20 +286,38 @@ function ACPHandler:process_tool_call(tool_call)
280286
self.ui_state[id] = { line_number = line_number, icon_id = icon_id }
281287
end
282288

283-
---Handle permission requests from the agent
289+
---Queue a permission request and present it when ready.
290+
---Agents may send multiple permission requests concurrently. Because the
291+
---approval UI uses buffer-local keymaps, presenting more than one at a time
292+
---would cause the second set of keymaps to overwrite the first, leaving
293+
---the earlier request unresolvable and the agent hanging. We queue them
294+
---and present one at a time.
284295
---@param request table
285296
---@return nil
286297
function ACPHandler:handle_permission_request(request)
287-
local tool_call = request.tool_call
298+
self._permission.queue:push(request)
299+
self:_process_next_permission()
300+
end
301+
302+
---Pop the next permission request from the queue and present it
303+
---@return nil
304+
function ACPHandler:_process_next_permission()
305+
if self._permission.active or self._permission.queue:is_empty() then
306+
return
307+
end
308+
309+
self._permission.active = true
310+
local request = self._permission.queue:pop()
288311

312+
-- Merge cached tool call data so the diff UI can activate
313+
local tool_call = request.tool_call
289314
if
290315
type(tool_call) == "table"
291316
and tool_call.toolCallId
292317
and (tool_call.content == nil or tool_call.content == vim.NIL)
293318
then
294319
local cached = self.tools[tool_call.toolCallId]
295320
if cached then
296-
-- Merge the cached tool call details into the request's tool call to enable the diff UI to activate
297321
request.tool_call = merge_tool_call(cached, tool_call)
298322
end
299323
end
@@ -308,11 +332,31 @@ function ACPHandler:handle_permission_request(request)
308332
end, request.options or {}))
309333
)
310334

335+
-- Wrap respond so we present the next queued permission after the user decides
336+
local original_respond = request.respond
337+
request.respond = function(option_id, canceled)
338+
original_respond(option_id, canceled)
339+
self._permission.active = false
340+
self:_process_next_permission()
341+
end
342+
311343
return require("codecompanion.interactions.chat.acp.request_permission").confirm(self.chat, request)
312344
end
313345

346+
---Reject all queued permission requests (e.g. on error or completion with pending items)
347+
---@return nil
348+
function ACPHandler:_clear_permission_queue()
349+
while not self._permission.queue:is_empty() do
350+
local request = self._permission.queue:pop()
351+
pcall(request.respond, nil, true)
352+
end
353+
self._permission.active = false
354+
end
355+
314356
---Handle completion
315357
function ACPHandler:handle_completion()
358+
self:_clear_permission_queue()
359+
316360
if not self.chat.status or self.chat.status == "" then
317361
self.chat.status = "success"
318362
end
@@ -323,6 +367,8 @@ end
323367
---Handle errors
324368
---@param error string
325369
function ACPHandler:handle_error(error)
370+
self:_clear_permission_queue()
371+
326372
self.chat.status = "error"
327373
log:error("[ACP::Handler] %s", error)
328374

tests/interactions/chat/acp/test_handler.lua

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,241 @@ T["ACPHandler"]["handles no connection"] = function()
672672
h.eq("\\cost", result.content)
673673
end
674674

675+
T["ACPHandler"]["Permission Queue"] = new_set()
676+
677+
T["ACPHandler"]["Permission Queue"]["queues concurrent requests and presents one at a time"] = function()
678+
local result = child.lua([[
679+
local chat = h.setup_chat_buffer({}, {
680+
name = "test_acp",
681+
config = {
682+
name = "test_acp",
683+
type = "acp",
684+
handlers = { form_messages = function(a, m) return m end }
685+
}
686+
})
687+
688+
local ACPHandler = require("codecompanion.interactions.chat.acp.handler")
689+
local handler = ACPHandler.new(chat)
690+
691+
-- Track which requests reach the permission UI
692+
local confirmed = {}
693+
package.loaded["codecompanion.interactions.chat.acp.request_permission"] = {
694+
confirm = function(chat_arg, request)
695+
table.insert(confirmed, request)
696+
end
697+
}
698+
699+
-- Send three permission requests concurrently
700+
handler:handle_permission_request({
701+
tool_call = { toolCallId = "tool_1", kind = "edit", title = "Edit file A" },
702+
options = { { kind = "allow_once", optionId = "allow", name = "Allow" } },
703+
respond = function() end,
704+
})
705+
handler:handle_permission_request({
706+
tool_call = { toolCallId = "tool_2", kind = "edit", title = "Edit file B" },
707+
options = { { kind = "allow_once", optionId = "allow", name = "Allow" } },
708+
respond = function() end,
709+
})
710+
handler:handle_permission_request({
711+
tool_call = { toolCallId = "tool_3", kind = "edit", title = "Edit file C" },
712+
options = { { kind = "allow_once", optionId = "allow", name = "Allow" } },
713+
respond = function() end,
714+
})
715+
716+
return {
717+
confirmed_count = #confirmed,
718+
first_id = confirmed[1] and confirmed[1].tool_call.toolCallId,
719+
queue_count = handler._permission.queue:count(),
720+
active = handler._permission.active,
721+
}
722+
]])
723+
724+
h.eq(1, result.confirmed_count)
725+
h.eq("tool_1", result.first_id)
726+
h.eq(2, result.queue_count)
727+
h.is_true(result.active)
728+
end
729+
730+
T["ACPHandler"]["Permission Queue"]["presents next request after user responds"] = function()
731+
local result = child.lua([[
732+
local chat = h.setup_chat_buffer({}, {
733+
name = "test_acp",
734+
config = {
735+
name = "test_acp",
736+
type = "acp",
737+
handlers = { form_messages = function(a, m) return m end }
738+
}
739+
})
740+
741+
local ACPHandler = require("codecompanion.interactions.chat.acp.handler")
742+
local handler = ACPHandler.new(chat)
743+
744+
local confirmed = {}
745+
package.loaded["codecompanion.interactions.chat.acp.request_permission"] = {
746+
confirm = function(chat_arg, request)
747+
table.insert(confirmed, request)
748+
end
749+
}
750+
751+
local responses = {}
752+
local make_respond = function(id)
753+
return function(option_id, canceled)
754+
table.insert(responses, { id = id, option_id = option_id, canceled = canceled })
755+
end
756+
end
757+
758+
handler:handle_permission_request({
759+
tool_call = { toolCallId = "tool_1" },
760+
options = { { kind = "allow_once", optionId = "allow", name = "Allow" } },
761+
respond = make_respond("tool_1"),
762+
})
763+
handler:handle_permission_request({
764+
tool_call = { toolCallId = "tool_2" },
765+
options = { { kind = "allow_once", optionId = "allow", name = "Allow" } },
766+
respond = make_respond("tool_2"),
767+
})
768+
769+
-- Simulate user accepting the first request
770+
confirmed[1].respond("allow", false)
771+
772+
return {
773+
confirmed_count = #confirmed,
774+
second_id = confirmed[2] and confirmed[2].tool_call.toolCallId,
775+
responses = responses,
776+
queue_empty = handler._permission.queue:is_empty(),
777+
active = handler._permission.active,
778+
}
779+
]])
780+
781+
h.eq(2, result.confirmed_count)
782+
h.eq("tool_2", result.second_id)
783+
h.eq("tool_1", result.responses[1].id)
784+
h.eq("allow", result.responses[1].option_id)
785+
h.is_true(result.queue_empty)
786+
h.is_true(result.active)
787+
end
788+
789+
T["ACPHandler"]["Permission Queue"]["clears queue on completion"] = function()
790+
local result = child.lua([[
791+
local chat = h.setup_chat_buffer({}, {
792+
name = "test_acp",
793+
config = {
794+
name = "test_acp",
795+
type = "acp",
796+
handlers = { form_messages = function(a, m) return m end }
797+
}
798+
})
799+
800+
local ACPHandler = require("codecompanion.interactions.chat.acp.handler")
801+
local handler = ACPHandler.new(chat)
802+
803+
local confirmed = {}
804+
package.loaded["codecompanion.interactions.chat.acp.request_permission"] = {
805+
confirm = function(chat_arg, request)
806+
table.insert(confirmed, request)
807+
end
808+
}
809+
810+
local rejected = {}
811+
local make_respond = function(id)
812+
return function(option_id, canceled)
813+
if canceled then
814+
table.insert(rejected, id)
815+
end
816+
end
817+
end
818+
819+
-- Queue up three requests
820+
handler:handle_permission_request({
821+
tool_call = { toolCallId = "tool_1" },
822+
options = {},
823+
respond = make_respond("tool_1"),
824+
})
825+
handler:handle_permission_request({
826+
tool_call = { toolCallId = "tool_2" },
827+
options = {},
828+
respond = make_respond("tool_2"),
829+
})
830+
handler:handle_permission_request({
831+
tool_call = { toolCallId = "tool_3" },
832+
options = {},
833+
respond = make_respond("tool_3"),
834+
})
835+
836+
-- Simulate completion while requests are still queued
837+
chat.done = function() end
838+
handler:handle_completion()
839+
840+
return {
841+
queue_empty = handler._permission.queue:is_empty(),
842+
active = handler._permission.active,
843+
rejected = rejected,
844+
}
845+
]])
846+
847+
h.is_true(result.queue_empty)
848+
h.is_false(result.active)
849+
h.eq({ "tool_2", "tool_3" }, result.rejected)
850+
end
851+
852+
T["ACPHandler"]["Permission Queue"]["clears queue on error"] = function()
853+
local result = child.lua([[
854+
local chat = h.setup_chat_buffer({}, {
855+
name = "test_acp",
856+
config = {
857+
name = "test_acp",
858+
type = "acp",
859+
handlers = { form_messages = function(a, m) return m end }
860+
}
861+
})
862+
863+
local ACPHandler = require("codecompanion.interactions.chat.acp.handler")
864+
local handler = ACPHandler.new(chat)
865+
866+
local confirmed = {}
867+
package.loaded["codecompanion.interactions.chat.acp.request_permission"] = {
868+
confirm = function(chat_arg, request)
869+
table.insert(confirmed, request)
870+
end
871+
}
872+
873+
local rejected = {}
874+
local make_respond = function(id)
875+
return function(option_id, canceled)
876+
if canceled then
877+
table.insert(rejected, id)
878+
end
879+
end
880+
end
881+
882+
handler:handle_permission_request({
883+
tool_call = { toolCallId = "tool_1" },
884+
options = {},
885+
respond = make_respond("tool_1"),
886+
})
887+
handler:handle_permission_request({
888+
tool_call = { toolCallId = "tool_2" },
889+
options = {},
890+
respond = make_respond("tool_2"),
891+
})
892+
893+
-- Stub add_buf_message and done
894+
chat.add_buf_message = function() end
895+
chat.done = function() end
896+
handler:handle_error("Something went wrong")
897+
898+
return {
899+
queue_empty = handler._permission.queue:is_empty(),
900+
active = handler._permission.active,
901+
rejected = rejected,
902+
}
903+
]])
904+
905+
h.is_true(result.queue_empty)
906+
h.is_false(result.active)
907+
h.eq({ "tool_2" }, result.rejected)
908+
end
909+
675910
T["ACPHandler"]["Config Options"] = new_set()
676911

677912
T["ACPHandler"]["Config Options"]["updates metadata with config options"] = function()

0 commit comments

Comments
 (0)