diff --git a/lua/opencode/api_client.lua b/lua/opencode/api_client.lua index 056aebce..96781ead 100644 --- a/lua/opencode/api_client.lua +++ b/lua/opencode/api_client.lua @@ -304,6 +304,13 @@ function OpencodeApiClient:unrevert_messages(id, directory) return self:_call('/session/' .. id .. '/unrevert', 'POST', nil, { directory = directory }) end +--- List pending permissions +--- @param directory string|nil Directory path +--- @return Promise +function OpencodeApiClient:list_permissions(directory) + return self:_call('/permission', 'GET', nil, { directory = directory }) +end + --- Respond to a permission request --- @param id string Session ID (required) --- @param permissionID string Permission ID (required) diff --git a/lua/opencode/ui/permission_window.lua b/lua/opencode/ui/permission_window.lua index af01fab8..2627d9c0 100644 --- a/lua/opencode/ui/permission_window.lua +++ b/lua/opencode/ui/permission_window.lua @@ -8,6 +8,73 @@ M._permission_queue = {} M._dialog = nil M._processing = false +---Get the tool identifiers from a permission (nested or root-level). +---@param permission OpencodePermission|nil +---@return string|nil call_id +---@return string|nil message_id +local function get_tool_ids(permission) + if not permission then + return nil, nil + end + local tool = permission.tool + local call_id = (tool and tool.callID) or permission.callID + local message_id = (tool and tool.messageID) or permission.messageID + return call_id, message_id +end + +---Find the message part that corresponds to a permission request. +---@param permission OpencodePermission|nil +---@return OpencodeMessagePart|nil +local function get_permission_part(permission) + local call_id, message_id = get_tool_ids(permission) + if not message_id or message_id == '' then + return nil + end + + if state.messages then + for _, message in ipairs(state.messages) do + if message.info and message.info.id == message_id then + for _, part in ipairs(message.parts or {}) do + if call_id and call_id ~= '' then + if part.callID == call_id then + return part + end + else + return part + end + end + end + end + end + + if permission and permission.sessionID and permission.sessionID ~= '' then + local render_state = require('opencode.ui.renderer.ctx').render_state + for _, part in ipairs(render_state:get_child_session_parts(permission.sessionID) or {}) do + if call_id and call_id ~= '' then + if part.callID == call_id then + return part + end + else + return part + end + end + end +end + +---Check whether a permission has already been resolved (completed, error, etc.) +---by inspecting the corresponding message part's status. +---@param permission OpencodePermission|nil +---@return boolean +local function is_resolved_permission(permission) + local part = get_permission_part(permission) + if not part or not part.state then + return false + end + + local part_status = part.state.status + return part_status ~= nil and part_status ~= '' and part_status ~= 'pending' and part_status ~= 'running' +end + ---Add permission to queue ---@param permission OpencodePermission function M.add_permission(permission) @@ -269,6 +336,68 @@ function M._clear_dialog() end end +---Query the server for pending permissions and restore any that belong +---to the active session. Mirrors question_window.restore_pending_question. +---@param session_id string|nil +function M.restore_pending_permissions(session_id) + local Promise = require('opencode.promise') + if not state.api_client or not session_id or session_id == '' then + return Promise.new():resolve(nil) + end + + return state.api_client:list_permissions() + :and_then(function(permissions) + if not permissions or type(permissions) ~= 'table' then + return + end + + local events = require('opencode.ui.renderer.events') + local render_state = require('opencode.ui.renderer.ctx').render_state + + for _, permission in ipairs(permissions) do + if permission and permission.id then + -- Check if this permission belongs to the active session or + -- one of its child sessions (task tool). + local belongs = permission.sessionID == session_id + if not belongs and permission.sessionID and permission.sessionID ~= '' then + belongs = render_state:get_task_part_by_child_session(permission.sessionID) ~= nil + end + if not belongs then + local tool = permission.tool + local tool_message_id = tool and tool.messageID + if tool_message_id and state.messages then + for _, message in ipairs(state.messages) do + if message.info and message.info.id == tool_message_id then + belongs = true + break + end + end + end + end + + if belongs and not is_resolved_permission(permission) then + -- Check if already queued (avoid duplicate) + local already_queued = false + for _, existing in ipairs(M._permission_queue) do + if existing.id == permission.id then + already_queued = true + break + end + end + if not already_queued then + events.on_permission_updated(permission) + end + end + end + end + end) + :catch(function(err) + vim.schedule(function() + vim.notify('Failed to restore pending permissions: ' .. vim.inspect(err), vim.log.levels.WARN) + end) + end) +end + ---Check if we have permissions ---@return boolean function M.has_permissions() diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index c96bdc19..c0f9e06c 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -246,16 +246,8 @@ M.on_session_updated = events.on_session_updated function M.reset() ctx:reset() output_window.clear() - - local permissions = state.pending_permissions or {} - if #permissions > 0 and state.api_client then - for _, permission in ipairs(permissions) do - require('opencode.api').permission_deny(permission) - end - end permission_window.clear_all() state.renderer.reset() - flush.trigger_on_data_rendered() end @@ -405,10 +397,13 @@ function M.render_full_session() return Promise.new():resolve(nil) end return fetch_session():and_then(function(session_data) - M._render_full_session_data(session_data, { restore_model_from_messages = true }) + M._render_full_session_data(session_data, { + restore_model_from_messages = true, + }) local active_session = state.active_session if active_session and active_session.id then require('opencode.ui.question_window').restore_pending_question(active_session.id) + permission_window.restore_pending_permissions(active_session.id) end return session_data end) diff --git a/tests/unit/permission_window_spec.lua b/tests/unit/permission_window_spec.lua index 4f4ca547..bf6d569f 100644 --- a/tests/unit/permission_window_spec.lua +++ b/tests/unit/permission_window_spec.lua @@ -1,5 +1,6 @@ local permission_window = require('opencode.ui.permission_window') local Output = require('opencode.ui.output') +local stub = require('luassert.stub') describe('permission_window', function() after_each(function() @@ -345,6 +346,251 @@ describe('permission_window', function() end) end) + describe('restore_pending_permissions', function() + local Promise = require('opencode.promise') + local state = require('opencode.state') + local events = require('opencode.ui.renderer.events') + + after_each(function() + state.jobs.set_api_client(nil) + state.renderer.set_messages({}) + end) + + it('skips permissions whose tool part has completed status', function() + state.jobs.set_api_client({ + list_permissions = function() + return Promise.new():resolve({ + { + id = 'perm_resolved', + sessionID = 'sess1', + tool = { messageID = 'msg_1', callID = 'call_1' }, + }, + }) + end, + }) + state.renderer.set_messages({ + { + info = { id = 'msg_1' }, + parts = { + { callID = 'call_1', state = { status = 'completed' } }, + }, + }, + }) + + local on_permission_stub = stub(events, 'on_permission_updated') + + permission_window.restore_pending_permissions('sess1'):wait() + + assert.stub(on_permission_stub).was_not_called() + on_permission_stub:revert() + end) + + it('skips permissions whose tool part has error status', function() + state.jobs.set_api_client({ + list_permissions = function() + return Promise.new():resolve({ + { + id = 'perm_error', + sessionID = 'sess1', + tool = { messageID = 'msg_1', callID = 'call_1' }, + }, + }) + end, + }) + state.renderer.set_messages({ + { + info = { id = 'msg_1' }, + parts = { + { callID = 'call_1', state = { status = 'error' } }, + }, + }, + }) + + local on_permission_stub = stub(events, 'on_permission_updated') + + permission_window.restore_pending_permissions('sess1'):wait() + + assert.stub(on_permission_stub).was_not_called() + on_permission_stub:revert() + end) + + it('restores permissions whose tool part is still pending', function() + state.jobs.set_api_client({ + list_permissions = function() + return Promise.new():resolve({ + { + id = 'perm_pending', + sessionID = 'sess1', + tool = { messageID = 'msg_1', callID = 'call_1' }, + }, + }) + end, + }) + state.renderer.set_messages({ + { + info = { id = 'msg_1' }, + parts = { + { callID = 'call_1', state = { status = 'pending' } }, + }, + }, + }) + + local on_permission_stub = stub(events, 'on_permission_updated') + + permission_window.restore_pending_permissions('sess1'):wait() + + assert.stub(on_permission_stub).was_called(1) + on_permission_stub:revert() + end) + + it('restores permissions whose tool part is running', function() + state.jobs.set_api_client({ + list_permissions = function() + return Promise.new():resolve({ + { + id = 'perm_running', + sessionID = 'sess1', + tool = { messageID = 'msg_1', callID = 'call_1' }, + }, + }) + end, + }) + state.renderer.set_messages({ + { + info = { id = 'msg_1' }, + parts = { + { callID = 'call_1', state = { status = 'running' } }, + }, + }, + }) + + local on_permission_stub = stub(events, 'on_permission_updated') + + permission_window.restore_pending_permissions('sess1'):wait() + + assert.stub(on_permission_stub).was_called(1) + on_permission_stub:revert() + end) + + it('restores permissions when no matching message part is found', function() + state.jobs.set_api_client({ + list_permissions = function() + return Promise.new():resolve({ + { + id = 'perm_no_part', + sessionID = 'sess1', + tool = { messageID = 'msg_unknown', callID = 'call_unknown' }, + }, + }) + end, + }) + state.renderer.set_messages({}) + + local on_permission_stub = stub(events, 'on_permission_updated') + + permission_window.restore_pending_permissions('sess1'):wait() + + assert.stub(on_permission_stub).was_called(1) + on_permission_stub:revert() + end) + + it('restores permissions without tool identifiers', function() + state.jobs.set_api_client({ + list_permissions = function() + return Promise.new():resolve({ + { + id = 'perm_no_tool', + sessionID = 'sess1', + }, + }) + end, + }) + state.renderer.set_messages({}) + + local on_permission_stub = stub(events, 'on_permission_updated') + + permission_window.restore_pending_permissions('sess1'):wait() + + assert.stub(on_permission_stub).was_called(1) + on_permission_stub:revert() + end) + + it('handles mix of resolved and pending permissions', function() + state.jobs.set_api_client({ + list_permissions = function() + return Promise.new():resolve({ + { + id = 'perm_done', + sessionID = 'sess1', + tool = { messageID = 'msg_1', callID = 'call_1' }, + }, + { + id = 'perm_active', + sessionID = 'sess1', + tool = { messageID = 'msg_2', callID = 'call_2' }, + }, + }) + end, + }) + state.renderer.set_messages({ + { + info = { id = 'msg_1' }, + parts = { + { callID = 'call_1', state = { status = 'completed' } }, + }, + }, + { + info = { id = 'msg_2' }, + parts = { + { callID = 'call_2', state = { status = 'pending' } }, + }, + }, + }) + + local on_permission_stub = stub(events, 'on_permission_updated') + + permission_window.restore_pending_permissions('sess1'):wait() + + assert.stub(on_permission_stub).was_called(1) + assert.stub(on_permission_stub).was_called_with({ + id = 'perm_active', + sessionID = 'sess1', + tool = { messageID = 'msg_2', callID = 'call_2' }, + }) + on_permission_stub:revert() + end) + + it('uses root-level callID/messageID when tool field is absent', function() + state.jobs.set_api_client({ + list_permissions = function() + return Promise.new():resolve({ + { + id = 'perm_root_ids', + sessionID = 'sess1', + messageID = 'msg_1', + callID = 'call_1', + }, + }) + end, + }) + state.renderer.set_messages({ + { + info = { id = 'msg_1' }, + parts = { + { callID = 'call_1', state = { status = 'completed' } }, + }, + }, + }) + + local on_permission_stub = stub(events, 'on_permission_updated') + + permission_window.restore_pending_permissions('sess1'):wait() + + assert.stub(on_permission_stub).was_not_called() + on_permission_stub:revert() + end) + end) + describe('add_permission correlation', function() it('stores messageID and callID from permission.tool', function() local permission = { diff --git a/tests/unit/services_session_runtime_spec.lua b/tests/unit/services_session_runtime_spec.lua index 6fe8acfa..1c211a6c 100644 --- a/tests/unit/services_session_runtime_spec.lua +++ b/tests/unit/services_session_runtime_spec.lua @@ -391,6 +391,52 @@ describe('opencode.services.session_runtime', function() mounted_stub:revert() state.ui.set_windows(nil) end) + + it('restores pending permissions after a full session render', function() + local renderer = require('opencode.ui.renderer') + local permission_window = require('opencode.ui.permission_window') + local events = require('opencode.ui.renderer.events') + + state.session.set_active({ id = 'sess1' }) + state.ui.set_windows({ output_buf = 1, output_win = 2 }) + + local mounted_stub = stub(require('opencode.ui.output_window'), 'mounted').returns(true) + local fetch_stub = stub(session, 'get_messages').invokes(function() + return Promise.new():resolve({}) + end) + local render_stub = stub(renderer, '_render_full_session_data') + local list_questions_stub = stub(state.api_client, 'list_questions').invokes(function() + return Promise.new():resolve({}) + end) + local list_permissions_stub = stub(state.api_client, 'list_permissions').invokes(function() + return Promise.new():resolve({ + { + id = 'perm1', + sessionID = 'sess1', + permission = 'bash', + patterns = { 'echo hello' }, + }, + }) + end) + local on_permission_stub = stub(events, 'on_permission_updated') + + renderer.render_full_session():wait() + + assert.stub(on_permission_stub).was_called_with({ + id = 'perm1', + sessionID = 'sess1', + permission = 'bash', + patterns = { 'echo hello' }, + }) + + on_permission_stub:revert() + list_permissions_stub:revert() + list_questions_stub:revert() + render_stub:revert() + fetch_stub:revert() + mounted_stub:revert() + state.ui.set_windows(nil) + end) end) describe('markdown rendering metadata', function() diff --git a/tests/unit/services_spec_support.lua b/tests/unit/services_spec_support.lua index 18817d47..641cbe72 100644 --- a/tests/unit/services_spec_support.lua +++ b/tests/unit/services_spec_support.lua @@ -24,6 +24,9 @@ function M.mock_api_client() get_config = function() return Promise.new():resolve({ model = 'gpt-4' }) end, + list_permissions = function() + return Promise.new():resolve({}) + end, }) end