Skip to content

Commit 1a40067

Browse files
authored
fix(session): switch session when active child/grandchild's ancestor is deleted (#376)
1 parent 4ae5d45 commit 1a40067

2 files changed

Lines changed: 205 additions & 1 deletion

File tree

lua/opencode/ui/session_picker.lua

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,28 @@ local util = require('opencode.util')
55
local api = require('opencode.api')
66
local Promise = require('opencode.promise')
77

8+
---Check whether any session id in `delete_ids` is the session itself or an ancestor
9+
---@param session_id string
10+
---@param delete_ids table<string, boolean>
11+
---@param all_sessions Session[]
12+
---@return boolean
13+
function M._is_session_or_ancestor_deleted(session_id, delete_ids, all_sessions)
14+
local session_map = {}
15+
for _, s in ipairs(all_sessions) do
16+
session_map[s.id] = s
17+
end
18+
19+
local current_id = session_id
20+
while current_id do
21+
if delete_ids[current_id] then
22+
return true
23+
end
24+
local s = session_map[current_id]
25+
current_id = s and s.parentID or nil
26+
end
27+
return false
28+
end
29+
830
---Format session parts for session picker
931
---@param session Session object
1032
---@return PickerItem
@@ -62,7 +84,12 @@ function M.pick(sessions, callback)
6284
to_delete_ids[s.id] = true
6385
end
6486

65-
local deleting_current = state.active_session and to_delete_ids[state.active_session.id] or false
87+
local deleting_current = false
88+
if state.active_session then
89+
local session_mod = require('opencode.session')
90+
local all_sessions = session_mod.get_all_workspace_sessions():await() or {}
91+
deleting_current = M._is_session_or_ancestor_deleted(state.active_session.id, to_delete_ids, all_sessions)
92+
end
6693

6794
if deleting_current then
6895
local remaining = vim.tbl_filter(function(item)
@@ -73,6 +100,8 @@ function M.pick(sessions, callback)
73100
session_runtime.switch_session(remaining[1].id):await()
74101
else
75102
vim.notify('deleting current session, creating new session')
103+
state.model.clear()
104+
require('opencode.services.agent_model').ensure_current_mode():await()
76105
state.session.set_active(session_runtime.create_new_session():await())
77106
end
78107
end

tests/unit/session_picker_spec.lua

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
-- tests/unit/session_picker_spec.lua
2+
-- Tests for session_picker helpers and delete action behaviour
3+
4+
local session_picker = require('opencode.ui.session_picker')
5+
local session_mod = require('opencode.session')
6+
local session_runtime = require('opencode.services.session_runtime')
7+
local state = require('opencode.state')
8+
local store = require('opencode.state.store')
9+
local Promise = require('opencode.promise')
10+
local stub = require('luassert.stub')
11+
local assert = require('luassert')
12+
local support = require('tests.unit.services_spec_support')
13+
14+
describe('opencode.ui.session_picker', function()
15+
-- -----------------------------------------------------------------------
16+
-- Pure unit tests for the helper – no mocks needed
17+
-- -----------------------------------------------------------------------
18+
describe('_is_session_or_ancestor_deleted', function()
19+
local root = { id = 'root', parentID = nil }
20+
local child = { id = 'child', parentID = 'root' }
21+
local grandchild = { id = 'grandchild', parentID = 'child' }
22+
local unrelated = { id = 'unrelated', parentID = nil }
23+
local all_sessions = { root, child, grandchild, unrelated }
24+
25+
it('returns true when the session itself is in the delete set', function()
26+
assert.is_true(session_picker._is_session_or_ancestor_deleted('child', { child = true }, all_sessions))
27+
end)
28+
29+
it('returns true when the direct parent is in the delete set', function()
30+
assert.is_true(session_picker._is_session_or_ancestor_deleted('child', { root = true }, all_sessions))
31+
end)
32+
33+
it('returns true when a grandparent is in the delete set', function()
34+
assert.is_true(session_picker._is_session_or_ancestor_deleted('grandchild', { root = true }, all_sessions))
35+
end)
36+
37+
it('returns false when an unrelated session is deleted', function()
38+
assert.is_false(session_picker._is_session_or_ancestor_deleted('child', { unrelated = true }, all_sessions))
39+
end)
40+
41+
it('returns false when only a sibling is deleted', function()
42+
local sibling = { id = 'sibling', parentID = 'root' }
43+
assert.is_false(session_picker._is_session_or_ancestor_deleted('child', { sibling = true }, { root, child, sibling, grandchild }))
44+
end)
45+
46+
it('returns false for a root session when an unrelated root is deleted', function()
47+
assert.is_false(session_picker._is_session_or_ancestor_deleted('root', { unrelated = true }, all_sessions))
48+
end)
49+
50+
it('returns true for root session when root itself is deleted', function()
51+
assert.is_true(session_picker._is_session_or_ancestor_deleted('root', { root = true }, all_sessions))
52+
end)
53+
end)
54+
55+
-- -----------------------------------------------------------------------
56+
-- Integration tests: delete action triggers switch when parent/grandparent
57+
-- of the active session is deleted
58+
-- -----------------------------------------------------------------------
59+
describe('delete action – session switch on ancestor deletion', function()
60+
local original
61+
local switch_stub
62+
63+
local root_session = { id = 'root', parentID = nil, title = 'Root', time = { updated = '2024-01-01' } }
64+
local other_root = { id = 'other-root', parentID = nil, title = 'Other', time = { updated = '2024-01-01' } }
65+
local child_session = { id = 'child', parentID = 'root', title = 'Child', time = { updated = '2024-01-01' } }
66+
local grandchild_session = { id = 'grandchild', parentID = 'child', title = 'Grandchild', time = { updated = '2024-01-01' } }
67+
68+
before_each(function()
69+
original = support.snapshot_state()
70+
71+
vim.schedule = function(fn) fn() end
72+
73+
support.mock_api_client()
74+
75+
-- Stub delete_session on the api_client so it doesn't error
76+
state.api_client.delete_session = function(_, _id)
77+
return Promise.new():resolve(true)
78+
end
79+
80+
-- Stub get_all_workspace_sessions to return our fixture tree
81+
stub(session_mod, 'get_all_workspace_sessions').invokes(function()
82+
return Promise.new():resolve({ root_session, other_root, child_session, grandchild_session })
83+
end)
84+
85+
-- Stub switch_session so we can assert it was called
86+
switch_stub = stub(session_runtime, 'switch_session').invokes(function(_id)
87+
return Promise.new():resolve(true)
88+
end)
89+
end)
90+
91+
after_each(function()
92+
support.restore_state(original)
93+
if session_mod.get_all_workspace_sessions.revert then
94+
session_mod.get_all_workspace_sessions:revert()
95+
end
96+
if session_runtime.switch_session.revert then
97+
session_runtime.switch_session:revert()
98+
end
99+
end)
100+
101+
-- Helper: build a minimal opts table with items and invoke the delete fn
102+
local function run_delete(active, items_in_picker, sessions_to_delete)
103+
state.session.set_active(active)
104+
105+
-- Extract the delete action fn from the picker actions by opening a
106+
-- dummy picker and grabbing the action directly from the module.
107+
-- Because `pick()` closes over the actions, we re-create them here
108+
-- by invoking the delete fn directly through a fake opts table.
109+
local delete_fn = nil
110+
-- Monkey-patch base_picker.pick to capture the actions
111+
local base_picker = require('opencode.ui.base_picker')
112+
local orig_pick = base_picker.pick
113+
base_picker.pick = function(opts)
114+
-- grab delete fn from the actions passed in
115+
delete_fn = opts.actions.delete.fn
116+
end
117+
session_picker.pick(items_in_picker, function() end)
118+
base_picker.pick = orig_pick
119+
120+
assert.truthy(delete_fn, 'delete fn should have been captured')
121+
122+
local opts = { items = vim.deepcopy(items_in_picker) }
123+
delete_fn(sessions_to_delete, opts):wait()
124+
end
125+
126+
it('switches session when the active session direct parent is deleted', function()
127+
-- Active = child, deleting root (parent of child), other_root remains
128+
run_delete(child_session, { root_session, other_root }, root_session)
129+
130+
assert.stub(switch_stub).was_called()
131+
local called_with = switch_stub.calls[1].vals[1]
132+
assert.equals('other-root', called_with)
133+
end)
134+
135+
it('switches session when active session grandparent is deleted', function()
136+
-- Active = grandchild, deleting root (grandparent), other_root remains
137+
run_delete(grandchild_session, { root_session, other_root }, root_session)
138+
139+
assert.stub(switch_stub).was_called()
140+
local called_with = switch_stub.calls[1].vals[1]
141+
assert.equals('other-root', called_with)
142+
end)
143+
144+
it('does NOT switch session when an unrelated root is deleted', function()
145+
-- Active = child (parentID=root), deleting other_root (unrelated)
146+
run_delete(child_session, { root_session, other_root }, other_root)
147+
148+
assert.stub(switch_stub).was_not_called()
149+
end)
150+
151+
it('resets agent mode when all sessions are deleted and a new session is created', function()
152+
local agent_model = require('opencode.services.agent_model')
153+
local store = require('opencode.state.store')
154+
155+
-- Simulate being stuck in a subagent mode (e.g. EXPLORE)
156+
store.set('current_mode', 'explore')
157+
158+
-- Stub ensure_current_mode to clear the mode (simulating default reset)
159+
local ensure_stub = stub(agent_model, 'ensure_current_mode').invokes(function()
160+
store.set('current_mode', 'default')
161+
return Promise.new():resolve(true)
162+
end)
163+
164+
-- Active = child session, only session in the picker is root (which is being deleted)
165+
-- No remaining sessions after deletion
166+
run_delete(child_session, { root_session }, root_session)
167+
168+
assert.stub(switch_stub).was_not_called()
169+
assert.stub(ensure_stub).was_called()
170+
assert.equals('default', state.current_mode)
171+
172+
ensure_stub:revert()
173+
end)
174+
end)
175+
end)

0 commit comments

Comments
 (0)