Skip to content

Commit 4a1642f

Browse files
committed
feat(session): add child_readonly config and restore/lock agent type for child sessions
- Add child_readonly config (default: true) to opt into messaging child sessions - Infer child session agent type from first assistant message on switch (session.agent may be polluted by prior incorrect agent params) - Lock agent type in child sessions, reject user-initiated mode switches - Child sessions scan messages forward (first is reliable), parent sessions scan backward (most recent is current choice) - Include subagents in agent validation list - Send inferred agent type when messaging child sessions to override polluted session.agent on backend
1 parent 3798e7f commit 4a1642f

12 files changed

Lines changed: 515 additions & 16 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,7 @@ require('opencode').setup({
341341
},
342342
},
343343
prompt_guard = nil, -- Optional function that returns boolean to control when prompts can be sent (see Prompt Guard section)
344+
child_readonly = true, -- When true (default), child sessions are read-only: messaging is blocked and input window is hidden on switch
344345

345346
-- User Hooks for custom behavior at certain events
346347
hooks = {

lua/opencode/config.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@ M.defaults = {
297297
},
298298
},
299299
prompt_guard = nil,
300+
child_readonly = true,
300301
hooks = {
301302
on_file_edited = nil,
302303
on_session_loaded = nil,

lua/opencode/services/agent_model.lua

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ M.cycle_variant = Promise.async(function()
106106
end)
107107

108108
M.switch_to_mode = Promise.async(function(mode)
109+
if state.active_session and state.active_session.parentID then
110+
log.notify('Cannot switch agent in child session', vim.log.levels.WARN)
111+
return false
112+
end
113+
109114
if not mode or mode == '' then
110115
log.notify('Mode cannot be empty', vim.log.levels.ERROR)
111116
return false
@@ -166,16 +171,24 @@ M.initialize_current_model = Promise.async(function(opts)
166171
opts = opts or {}
167172

168173
if opts.restore_from_messages and state.messages then
169-
for i = #state.messages, 1, -1 do
174+
-- Child sessions scan forward (first message is reliable);
175+
-- parent sessions scan backward (most recent is current choice)
176+
local is_child = state.active_session and state.active_session.parentID ~= nil
177+
local start_idx, end_idx, step = #state.messages, 1, -1
178+
if is_child then
179+
start_idx, end_idx, step = 1, #state.messages, 1
180+
end
181+
for i = start_idx, end_idx, step do
170182
local msg = state.messages[i]
171183
if msg and msg.info and msg.info.modelID and msg.info.providerID then
172184
local model_str = msg.info.providerID .. '/' .. msg.info.modelID
173185
if state.current_model ~= model_str then
174186
state.model.set_model(model_str)
175187
end
176188
if msg.info.mode and state.current_mode ~= msg.info.mode then
177-
local available_agents = config_file.get_opencode_agents():await()
178-
if vim.tbl_contains(available_agents, msg.info.mode) then
189+
local all_agents =
190+
vim.list_extend(config_file.get_opencode_agents():await(), config_file.get_subagents():await())
191+
if vim.tbl_contains(all_agents, msg.info.mode) then
179192
state.model.set_mode(msg.info.mode)
180193
end
181194
end

lua/opencode/services/messaging.lua

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ M.send_message = Promise.async(function(prompt, opts)
1818
return false
1919
end
2020

21-
if state.active_session.parentID then
21+
if state.active_session.parentID and config.child_readonly then
2222
return false
2323
end
2424

@@ -36,7 +36,9 @@ M.send_message = Promise.async(function(prompt, opts)
3636
state.context.set_current_context_config(opts.context)
3737
context.load()
3838
opts.model = opts.model or agent_model.initialize_current_model():await()
39-
opts.agent = opts.agent or state.current_mode or config.default_mode
39+
if opts.agent == nil then
40+
opts.agent = state.current_mode or config.default_mode
41+
end
4042
opts.variant = opts.variant or state.current_variant
4143
local params = {}
4244

lua/opencode/services/session_runtime.lua

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ local server_job = require('opencode.server_job')
66
local input_window = require('opencode.ui.input_window')
77
local util = require('opencode.util')
88
local config = require('opencode.config')
9+
local config_file = require('opencode.config_file')
910
local image_handler = require('opencode.image_handler')
1011
local Promise = require('opencode.promise')
1112
local log = require('opencode.log')
@@ -45,11 +46,63 @@ M.switch_session = Promise.async(function(session_id)
4546
local selected_session = session.get_by_id(session_id):await()
4647

4748
state.model.clear()
48-
agent_model.ensure_current_mode():await()
49+
50+
-- Child sessions: infer agent type from first assistant message
51+
-- (session.agent may reflect a polluted value from prior incorrect params)
52+
if selected_session and selected_session.parentID then
53+
state.session.set_active(selected_session)
54+
local ok, messages = pcall(function()
55+
return session.get_messages(selected_session):await()
56+
end)
57+
local restored = false
58+
if ok and messages then
59+
local all_agents = vim.list_extend(config_file.get_opencode_agents():await(), config_file.get_subagents():await())
60+
for i = 1, #messages do
61+
local msg = messages[i]
62+
if msg and msg.info and msg.info.mode then
63+
if vim.tbl_contains(all_agents, msg.info.mode) then
64+
state.model.set_mode(msg.info.mode)
65+
if msg.info.providerID and msg.info.modelID then
66+
state.model.set_model(msg.info.providerID .. '/' .. msg.info.modelID)
67+
end
68+
restored = true
69+
break
70+
end
71+
end
72+
end
73+
end
74+
if not restored then
75+
-- Fallback to session.agent, then default_mode
76+
if selected_session.agent then
77+
local all_agents =
78+
vim.list_extend(config_file.get_opencode_agents():await(), config_file.get_subagents():await())
79+
if vim.tbl_contains(all_agents, selected_session.agent) then
80+
state.model.set_mode(selected_session.agent)
81+
else
82+
state.model.set_mode(config.default_mode)
83+
end
84+
else
85+
log.notify('Could not infer agent type for child session', vim.log.levels.WARN)
86+
state.model.set_mode(config.default_mode)
87+
end
88+
end
89+
elseif selected_session and selected_session.agent then
90+
-- Parent session with agent info: restore directly
91+
local available_agents = config_file.get_opencode_agents():await()
92+
if vim.tbl_contains(available_agents, selected_session.agent) then
93+
state.model.set_mode(selected_session.agent)
94+
end
95+
if selected_session.model then
96+
local model_str = selected_session.model.providerID .. '/' .. selected_session.model.id
97+
state.model.set_model(model_str)
98+
end
99+
else
100+
agent_model.ensure_current_mode():await()
101+
end
49102

50103
state.session.set_active(selected_session)
51104
if state.ui.is_visible() then
52-
if selected_session and selected_session.parentID then
105+
if selected_session and selected_session.parentID and config.child_readonly then
53106
if not input_window.is_hidden() then
54107
input_window._hide()
55108
end

lua/opencode/types.lua

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@
126126
---@field time { created: number, updated: number }
127127
---@field id string
128128
---@field parentID string|nil
129+
---@field agent string|nil
130+
---@field model { id: string, providerID: string, variant?: string }|nil
129131
---@field revert? SessionRevertInfo
130132
---@field share? SessionShareInfo
131133

@@ -367,6 +369,7 @@
367369
---@field logging OpencodeLoggingConfig
368370
---@field debug OpencodeDebugConfig
369371
---@field prompt_guard? fun(mentioned_files: string[]): boolean
372+
---@field child_readonly boolean
370373
---@field hooks OpencodeHooks
371374
---@field quick_chat OpencodeQuickChatConfig
372375

lua/opencode/ui/input_window.lua

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -591,8 +591,7 @@ end
591591

592592
---Show the input window by recreating it
593593
function M._show()
594-
-- Child sessions must never show the input window
595-
if state.active_session and state.active_session.parentID then
594+
if state.active_session and state.active_session.parentID and config.child_readonly then
596595
return
597596
end
598597

lua/opencode/ui/ui.lua

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,8 @@ end
319319
---@param output_buf integer
320320
---@return { input_win: integer, output_win: integer }
321321
local function open_float(input_buf, output_buf)
322-
local output_config, input_config = float_layout.window_configs({ input_buf = input_buf, output_buf = output_buf }, true)
322+
local output_config, input_config =
323+
float_layout.window_configs({ input_buf = input_buf, output_buf = output_buf }, true)
323324
local output_win = float_layout.open_win(output_buf, true, output_config)
324325
local input_win = float_layout.open_win(input_buf, true, input_config)
325326

@@ -403,7 +404,7 @@ end
403404

404405
---@param opts? { restore_position?: boolean, start_insert?: boolean }
405406
function M.focus_input(opts)
406-
if state.active_session and state.active_session.parentID then
407+
if state.active_session and state.active_session.parentID and config.child_readonly then
407408
return
408409
end
409410

@@ -561,7 +562,7 @@ function M.toggle_pane()
561562
if state.windows and current_win == state.windows.input_win then
562563
output_window.focus_output(true)
563564
else
564-
if state.active_session and state.active_session.parentID then
565+
if state.active_session and state.active_session.parentID and config.child_readonly then
565566
return
566567
end
567568
input_window.focus_input()

tests/unit/input_window_spec.lua

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,5 +573,20 @@ describe('input_window', function()
573573
-- _hidden remains true because windows are nil, but the parentID guard was passed
574574
assert.is_true(input_window._hidden)
575575
end)
576+
577+
it('_show() proceeds for child session when child_readonly is false', function()
578+
state.session.set_active({ id = 'child1', parentID = 'parent1' })
579+
local config = require('opencode.config')
580+
local orig_readonly = config.values.child_readonly
581+
config.values.child_readonly = false
582+
input_window._hidden = true
583+
584+
-- _show will early-return due to missing windows, but it should pass the guard
585+
input_window._show()
586+
587+
-- _hidden remains true because windows are nil, but the parentID guard was passed
588+
assert.is_true(input_window._hidden)
589+
config.values.child_readonly = orig_readonly
590+
end)
576591
end)
577592
end)

tests/unit/services_agent_model_spec.lua

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ describe('opencode.services.agent_model', function()
146146
state.model.set_mode('plan')
147147

148148
stub(config_file, 'get_opencode_agents').returns(Promise.new():resolve({ 'plan', 'build' }))
149+
stub(config_file, 'get_subagents').returns(Promise.new():resolve({}))
149150

150151
state.renderer.set_messages({
151152
{
@@ -165,13 +166,15 @@ describe('opencode.services.agent_model', function()
165166
assert.equal('build', state.current_mode)
166167

167168
config_file.get_opencode_agents:revert()
169+
config_file.get_subagents:revert()
168170
end)
169171

170172
it('does not restore mode to a hidden agent from messages', function()
171173
state.model.set_model('openai/gpt-4.1')
172174
state.model.set_mode('build')
173175

174176
stub(config_file, 'get_opencode_agents').returns(Promise.new():resolve({ 'plan', 'build' }))
177+
stub(config_file, 'get_subagents').returns(Promise.new():resolve({}))
175178

176179
state.renderer.set_messages({
177180
{
@@ -191,5 +194,42 @@ describe('opencode.services.agent_model', function()
191194
assert.equal('build', state.current_mode)
192195

193196
config_file.get_opencode_agents:revert()
197+
config_file.get_subagents:revert()
198+
end)
199+
200+
it('rejects switch_to_mode in child session', function()
201+
state.session.set_active({ id = 'child1', parentID = 'parent1' })
202+
state.model.set_mode('build')
203+
204+
stub(config_file, 'get_opencode_agents').returns(Promise.new():resolve({ 'plan', 'build' }))
205+
206+
local promise = agent_model.switch_to_mode('plan')
207+
local success = promise:wait()
208+
209+
assert.is_false(success)
210+
assert.equal('build', state.current_mode)
211+
212+
config_file.get_opencode_agents:revert()
213+
state.session.clear_active()
214+
end)
215+
216+
it('allows switch_to_mode in parent session', function()
217+
state.session.set_active({ id = 'parent1' })
218+
state.store.set('current_mode', nil)
219+
state.store.set('current_model', nil)
220+
state.store.set('user_mode_model_map', {})
221+
222+
stub(config_file, 'get_opencode_agents').returns(Promise.new():resolve({ 'plan', 'build' }))
223+
stub(config_file, 'get_opencode_config').returns(Promise.new():resolve({}))
224+
225+
local promise = agent_model.switch_to_mode('plan')
226+
local success = promise:wait()
227+
228+
assert.is_true(success)
229+
assert.equal('plan', state.current_mode)
230+
231+
config_file.get_opencode_agents:revert()
232+
config_file.get_opencode_config:revert()
233+
state.session.clear_active()
194234
end)
195235
end)

0 commit comments

Comments
 (0)