Skip to content

Commit 9a938ea

Browse files
committed
fix: skip resolved permissions during session restore
1 parent 10ccc62 commit 9a938ea

2 files changed

Lines changed: 314 additions & 1 deletion

File tree

lua/opencode/ui/permission_window.lua

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,73 @@ M._permission_queue = {}
88
M._dialog = nil
99
M._processing = false
1010

11+
---Get the tool identifiers from a permission (nested or root-level).
12+
---@param permission OpencodePermission|nil
13+
---@return string|nil call_id
14+
---@return string|nil message_id
15+
local function get_tool_ids(permission)
16+
if not permission then
17+
return nil, nil
18+
end
19+
local tool = permission.tool
20+
local call_id = (tool and tool.callID) or permission.callID
21+
local message_id = (tool and tool.messageID) or permission.messageID
22+
return call_id, message_id
23+
end
24+
25+
---Find the message part that corresponds to a permission request.
26+
---@param permission OpencodePermission|nil
27+
---@return OpencodeMessagePart|nil
28+
local function get_permission_part(permission)
29+
local call_id, message_id = get_tool_ids(permission)
30+
if not message_id or message_id == '' then
31+
return nil
32+
end
33+
34+
if state.messages then
35+
for _, message in ipairs(state.messages) do
36+
if message.info and message.info.id == message_id then
37+
for _, part in ipairs(message.parts or {}) do
38+
if call_id and call_id ~= '' then
39+
if part.callID == call_id then
40+
return part
41+
end
42+
else
43+
return part
44+
end
45+
end
46+
end
47+
end
48+
end
49+
50+
if permission and permission.sessionID and permission.sessionID ~= '' then
51+
local render_state = require('opencode.ui.renderer.ctx').render_state
52+
for _, part in ipairs(render_state:get_child_session_parts(permission.sessionID) or {}) do
53+
if call_id and call_id ~= '' then
54+
if part.callID == call_id then
55+
return part
56+
end
57+
else
58+
return part
59+
end
60+
end
61+
end
62+
end
63+
64+
---Check whether a permission has already been resolved (completed, error, etc.)
65+
---by inspecting the corresponding message part's status.
66+
---@param permission OpencodePermission|nil
67+
---@return boolean
68+
local function is_resolved_permission(permission)
69+
local part = get_permission_part(permission)
70+
if not part or not part.state then
71+
return false
72+
end
73+
74+
local part_status = part.state.status
75+
return part_status ~= nil and part_status ~= '' and part_status ~= 'pending' and part_status ~= 'running'
76+
end
77+
1178
---Add permission to queue
1279
---@param permission OpencodePermission
1380
function M.add_permission(permission)
@@ -308,7 +375,7 @@ function M.restore_pending_permissions(session_id)
308375
end
309376
end
310377

311-
if belongs then
378+
if belongs and not is_resolved_permission(permission) then
312379
-- Check if already queued (avoid duplicate)
313380
local already_queued = false
314381
for _, existing in ipairs(M._permission_queue) do

tests/unit/permission_window_spec.lua

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
local permission_window = require('opencode.ui.permission_window')
22
local Output = require('opencode.ui.output')
3+
local stub = require('luassert.stub')
34

45
describe('permission_window', function()
56
after_each(function()
@@ -345,6 +346,251 @@ describe('permission_window', function()
345346
end)
346347
end)
347348

349+
describe('restore_pending_permissions', function()
350+
local Promise = require('opencode.promise')
351+
local state = require('opencode.state')
352+
local events = require('opencode.ui.renderer.events')
353+
354+
after_each(function()
355+
state.jobs.set_api_client(nil)
356+
state.renderer.set_messages({})
357+
end)
358+
359+
it('skips permissions whose tool part has completed status', function()
360+
state.jobs.set_api_client({
361+
list_permissions = function()
362+
return Promise.new():resolve({
363+
{
364+
id = 'perm_resolved',
365+
sessionID = 'sess1',
366+
tool = { messageID = 'msg_1', callID = 'call_1' },
367+
},
368+
})
369+
end,
370+
})
371+
state.renderer.set_messages({
372+
{
373+
info = { id = 'msg_1' },
374+
parts = {
375+
{ callID = 'call_1', state = { status = 'completed' } },
376+
},
377+
},
378+
})
379+
380+
local on_permission_stub = stub(events, 'on_permission_updated')
381+
382+
permission_window.restore_pending_permissions('sess1'):wait()
383+
384+
assert.stub(on_permission_stub).was_not_called()
385+
on_permission_stub:revert()
386+
end)
387+
388+
it('skips permissions whose tool part has error status', function()
389+
state.jobs.set_api_client({
390+
list_permissions = function()
391+
return Promise.new():resolve({
392+
{
393+
id = 'perm_error',
394+
sessionID = 'sess1',
395+
tool = { messageID = 'msg_1', callID = 'call_1' },
396+
},
397+
})
398+
end,
399+
})
400+
state.renderer.set_messages({
401+
{
402+
info = { id = 'msg_1' },
403+
parts = {
404+
{ callID = 'call_1', state = { status = 'error' } },
405+
},
406+
},
407+
})
408+
409+
local on_permission_stub = stub(events, 'on_permission_updated')
410+
411+
permission_window.restore_pending_permissions('sess1'):wait()
412+
413+
assert.stub(on_permission_stub).was_not_called()
414+
on_permission_stub:revert()
415+
end)
416+
417+
it('restores permissions whose tool part is still pending', function()
418+
state.jobs.set_api_client({
419+
list_permissions = function()
420+
return Promise.new():resolve({
421+
{
422+
id = 'perm_pending',
423+
sessionID = 'sess1',
424+
tool = { messageID = 'msg_1', callID = 'call_1' },
425+
},
426+
})
427+
end,
428+
})
429+
state.renderer.set_messages({
430+
{
431+
info = { id = 'msg_1' },
432+
parts = {
433+
{ callID = 'call_1', state = { status = 'pending' } },
434+
},
435+
},
436+
})
437+
438+
local on_permission_stub = stub(events, 'on_permission_updated')
439+
440+
permission_window.restore_pending_permissions('sess1'):wait()
441+
442+
assert.stub(on_permission_stub).was_called(1)
443+
on_permission_stub:revert()
444+
end)
445+
446+
it('restores permissions whose tool part is running', function()
447+
state.jobs.set_api_client({
448+
list_permissions = function()
449+
return Promise.new():resolve({
450+
{
451+
id = 'perm_running',
452+
sessionID = 'sess1',
453+
tool = { messageID = 'msg_1', callID = 'call_1' },
454+
},
455+
})
456+
end,
457+
})
458+
state.renderer.set_messages({
459+
{
460+
info = { id = 'msg_1' },
461+
parts = {
462+
{ callID = 'call_1', state = { status = 'running' } },
463+
},
464+
},
465+
})
466+
467+
local on_permission_stub = stub(events, 'on_permission_updated')
468+
469+
permission_window.restore_pending_permissions('sess1'):wait()
470+
471+
assert.stub(on_permission_stub).was_called(1)
472+
on_permission_stub:revert()
473+
end)
474+
475+
it('restores permissions when no matching message part is found', function()
476+
state.jobs.set_api_client({
477+
list_permissions = function()
478+
return Promise.new():resolve({
479+
{
480+
id = 'perm_no_part',
481+
sessionID = 'sess1',
482+
tool = { messageID = 'msg_unknown', callID = 'call_unknown' },
483+
},
484+
})
485+
end,
486+
})
487+
state.renderer.set_messages({})
488+
489+
local on_permission_stub = stub(events, 'on_permission_updated')
490+
491+
permission_window.restore_pending_permissions('sess1'):wait()
492+
493+
assert.stub(on_permission_stub).was_called(1)
494+
on_permission_stub:revert()
495+
end)
496+
497+
it('restores permissions without tool identifiers', function()
498+
state.jobs.set_api_client({
499+
list_permissions = function()
500+
return Promise.new():resolve({
501+
{
502+
id = 'perm_no_tool',
503+
sessionID = 'sess1',
504+
},
505+
})
506+
end,
507+
})
508+
state.renderer.set_messages({})
509+
510+
local on_permission_stub = stub(events, 'on_permission_updated')
511+
512+
permission_window.restore_pending_permissions('sess1'):wait()
513+
514+
assert.stub(on_permission_stub).was_called(1)
515+
on_permission_stub:revert()
516+
end)
517+
518+
it('handles mix of resolved and pending permissions', function()
519+
state.jobs.set_api_client({
520+
list_permissions = function()
521+
return Promise.new():resolve({
522+
{
523+
id = 'perm_done',
524+
sessionID = 'sess1',
525+
tool = { messageID = 'msg_1', callID = 'call_1' },
526+
},
527+
{
528+
id = 'perm_active',
529+
sessionID = 'sess1',
530+
tool = { messageID = 'msg_2', callID = 'call_2' },
531+
},
532+
})
533+
end,
534+
})
535+
state.renderer.set_messages({
536+
{
537+
info = { id = 'msg_1' },
538+
parts = {
539+
{ callID = 'call_1', state = { status = 'completed' } },
540+
},
541+
},
542+
{
543+
info = { id = 'msg_2' },
544+
parts = {
545+
{ callID = 'call_2', state = { status = 'pending' } },
546+
},
547+
},
548+
})
549+
550+
local on_permission_stub = stub(events, 'on_permission_updated')
551+
552+
permission_window.restore_pending_permissions('sess1'):wait()
553+
554+
assert.stub(on_permission_stub).was_called(1)
555+
assert.stub(on_permission_stub).was_called_with({
556+
id = 'perm_active',
557+
sessionID = 'sess1',
558+
tool = { messageID = 'msg_2', callID = 'call_2' },
559+
})
560+
on_permission_stub:revert()
561+
end)
562+
563+
it('uses root-level callID/messageID when tool field is absent', function()
564+
state.jobs.set_api_client({
565+
list_permissions = function()
566+
return Promise.new():resolve({
567+
{
568+
id = 'perm_root_ids',
569+
sessionID = 'sess1',
570+
messageID = 'msg_1',
571+
callID = 'call_1',
572+
},
573+
})
574+
end,
575+
})
576+
state.renderer.set_messages({
577+
{
578+
info = { id = 'msg_1' },
579+
parts = {
580+
{ callID = 'call_1', state = { status = 'completed' } },
581+
},
582+
},
583+
})
584+
585+
local on_permission_stub = stub(events, 'on_permission_updated')
586+
587+
permission_window.restore_pending_permissions('sess1'):wait()
588+
589+
assert.stub(on_permission_stub).was_not_called()
590+
on_permission_stub:revert()
591+
end)
592+
end)
593+
348594
describe('add_permission correlation', function()
349595
it('stores messageID and callID from permission.tool', function()
350596
local permission = {

0 commit comments

Comments
 (0)