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
31 changes: 30 additions & 1 deletion lua/opencode/ui/session_picker.lua
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,28 @@ local util = require('opencode.util')
local api = require('opencode.api')
local Promise = require('opencode.promise')

---Check whether any session id in `delete_ids` is the session itself or an ancestor
---@param session_id string
---@param delete_ids table<string, boolean>
---@param all_sessions Session[]
---@return boolean
function M._is_session_or_ancestor_deleted(session_id, delete_ids, all_sessions)
local session_map = {}
for _, s in ipairs(all_sessions) do
session_map[s.id] = s
end

local current_id = session_id
while current_id do
if delete_ids[current_id] then
return true
end
local s = session_map[current_id]
current_id = s and s.parentID or nil
end
return false
end

---Format session parts for session picker
---@param session Session object
---@return PickerItem
Expand Down Expand Up @@ -62,7 +84,12 @@ function M.pick(sessions, callback)
to_delete_ids[s.id] = true
end

local deleting_current = state.active_session and to_delete_ids[state.active_session.id] or false
local deleting_current = false
if state.active_session then
local session_mod = require('opencode.session')
local all_sessions = session_mod.get_all_workspace_sessions():await() or {}
deleting_current = M._is_session_or_ancestor_deleted(state.active_session.id, to_delete_ids, all_sessions)
end

if deleting_current then
local remaining = vim.tbl_filter(function(item)
Expand All @@ -73,6 +100,8 @@ function M.pick(sessions, callback)
session_runtime.switch_session(remaining[1].id):await()
else
vim.notify('deleting current session, creating new session')
state.model.clear()
require('opencode.services.agent_model').ensure_current_mode():await()
state.session.set_active(session_runtime.create_new_session():await())
end
end
Expand Down
175 changes: 175 additions & 0 deletions tests/unit/session_picker_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
-- tests/unit/session_picker_spec.lua
-- Tests for session_picker helpers and delete action behaviour

local session_picker = require('opencode.ui.session_picker')
local session_mod = require('opencode.session')
local session_runtime = require('opencode.services.session_runtime')
local state = require('opencode.state')
local store = require('opencode.state.store')
local Promise = require('opencode.promise')
local stub = require('luassert.stub')
local assert = require('luassert')
local support = require('tests.unit.services_spec_support')

describe('opencode.ui.session_picker', function()
-- -----------------------------------------------------------------------
-- Pure unit tests for the helper – no mocks needed
-- -----------------------------------------------------------------------
describe('_is_session_or_ancestor_deleted', function()
local root = { id = 'root', parentID = nil }
local child = { id = 'child', parentID = 'root' }
local grandchild = { id = 'grandchild', parentID = 'child' }
local unrelated = { id = 'unrelated', parentID = nil }
local all_sessions = { root, child, grandchild, unrelated }

it('returns true when the session itself is in the delete set', function()
assert.is_true(session_picker._is_session_or_ancestor_deleted('child', { child = true }, all_sessions))
end)

it('returns true when the direct parent is in the delete set', function()
assert.is_true(session_picker._is_session_or_ancestor_deleted('child', { root = true }, all_sessions))
end)

it('returns true when a grandparent is in the delete set', function()
assert.is_true(session_picker._is_session_or_ancestor_deleted('grandchild', { root = true }, all_sessions))
end)

it('returns false when an unrelated session is deleted', function()
assert.is_false(session_picker._is_session_or_ancestor_deleted('child', { unrelated = true }, all_sessions))
end)

it('returns false when only a sibling is deleted', function()
local sibling = { id = 'sibling', parentID = 'root' }
assert.is_false(session_picker._is_session_or_ancestor_deleted('child', { sibling = true }, { root, child, sibling, grandchild }))
end)

it('returns false for a root session when an unrelated root is deleted', function()
assert.is_false(session_picker._is_session_or_ancestor_deleted('root', { unrelated = true }, all_sessions))
end)

it('returns true for root session when root itself is deleted', function()
assert.is_true(session_picker._is_session_or_ancestor_deleted('root', { root = true }, all_sessions))
end)
end)

-- -----------------------------------------------------------------------
-- Integration tests: delete action triggers switch when parent/grandparent
-- of the active session is deleted
-- -----------------------------------------------------------------------
describe('delete action – session switch on ancestor deletion', function()
local original
local switch_stub

local root_session = { id = 'root', parentID = nil, title = 'Root', time = { updated = '2024-01-01' } }
local other_root = { id = 'other-root', parentID = nil, title = 'Other', time = { updated = '2024-01-01' } }
local child_session = { id = 'child', parentID = 'root', title = 'Child', time = { updated = '2024-01-01' } }
local grandchild_session = { id = 'grandchild', parentID = 'child', title = 'Grandchild', time = { updated = '2024-01-01' } }

before_each(function()
original = support.snapshot_state()

vim.schedule = function(fn) fn() end

support.mock_api_client()

-- Stub delete_session on the api_client so it doesn't error
state.api_client.delete_session = function(_, _id)
return Promise.new():resolve(true)
end

-- Stub get_all_workspace_sessions to return our fixture tree
stub(session_mod, 'get_all_workspace_sessions').invokes(function()
return Promise.new():resolve({ root_session, other_root, child_session, grandchild_session })
end)

-- Stub switch_session so we can assert it was called
switch_stub = stub(session_runtime, 'switch_session').invokes(function(_id)
return Promise.new():resolve(true)
end)
end)

after_each(function()
support.restore_state(original)
if session_mod.get_all_workspace_sessions.revert then
session_mod.get_all_workspace_sessions:revert()
end
if session_runtime.switch_session.revert then
session_runtime.switch_session:revert()
end
end)

-- Helper: build a minimal opts table with items and invoke the delete fn
local function run_delete(active, items_in_picker, sessions_to_delete)
state.session.set_active(active)

-- Extract the delete action fn from the picker actions by opening a
-- dummy picker and grabbing the action directly from the module.
-- Because `pick()` closes over the actions, we re-create them here
-- by invoking the delete fn directly through a fake opts table.
local delete_fn = nil
-- Monkey-patch base_picker.pick to capture the actions
local base_picker = require('opencode.ui.base_picker')
local orig_pick = base_picker.pick
base_picker.pick = function(opts)
-- grab delete fn from the actions passed in
delete_fn = opts.actions.delete.fn
end
session_picker.pick(items_in_picker, function() end)
base_picker.pick = orig_pick

assert.truthy(delete_fn, 'delete fn should have been captured')

local opts = { items = vim.deepcopy(items_in_picker) }
delete_fn(sessions_to_delete, opts):wait()
end

it('switches session when the active session direct parent is deleted', function()
-- Active = child, deleting root (parent of child), other_root remains
run_delete(child_session, { root_session, other_root }, root_session)

assert.stub(switch_stub).was_called()
local called_with = switch_stub.calls[1].vals[1]
assert.equals('other-root', called_with)
end)

it('switches session when active session grandparent is deleted', function()
-- Active = grandchild, deleting root (grandparent), other_root remains
run_delete(grandchild_session, { root_session, other_root }, root_session)

assert.stub(switch_stub).was_called()
local called_with = switch_stub.calls[1].vals[1]
assert.equals('other-root', called_with)
end)

it('does NOT switch session when an unrelated root is deleted', function()
-- Active = child (parentID=root), deleting other_root (unrelated)
run_delete(child_session, { root_session, other_root }, other_root)

assert.stub(switch_stub).was_not_called()
end)

it('resets agent mode when all sessions are deleted and a new session is created', function()
local agent_model = require('opencode.services.agent_model')
local store = require('opencode.state.store')

-- Simulate being stuck in a subagent mode (e.g. EXPLORE)
store.set('current_mode', 'explore')

-- Stub ensure_current_mode to clear the mode (simulating default reset)
local ensure_stub = stub(agent_model, 'ensure_current_mode').invokes(function()
store.set('current_mode', 'default')
return Promise.new():resolve(true)
end)

-- Active = child session, only session in the picker is root (which is being deleted)
-- No remaining sessions after deletion
run_delete(child_session, { root_session }, root_session)

assert.stub(switch_stub).was_not_called()
assert.stub(ensure_stub).was_called()
assert.equals('default', state.current_mode)

ensure_stub:revert()
end)
end)
end)
Loading