Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ require('opencode').setup({
},
},
prompt_guard = nil, -- Optional function that returns boolean to control when prompts can be sent (see Prompt Guard section)
child_readonly = true, -- When true (default), child sessions are read-only: messaging is blocked and input window is hidden on switch

-- User Hooks for custom behavior at certain events
hooks = {
Expand Down
1 change: 1 addition & 0 deletions lua/opencode/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ M.defaults = {
},
},
prompt_guard = nil,
child_readonly = true,
hooks = {
on_file_edited = nil,
on_session_loaded = nil,
Expand Down
19 changes: 16 additions & 3 deletions lua/opencode/services/agent_model.lua
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ M.cycle_variant = Promise.async(function()
end)

M.switch_to_mode = Promise.async(function(mode)
if state.active_session and state.active_session.parentID then
log.notify('Cannot switch agent in child session', vim.log.levels.WARN)
return false
end

if not mode or mode == '' then
log.notify('Mode cannot be empty', vim.log.levels.ERROR)
return false
Expand Down Expand Up @@ -166,16 +171,24 @@ M.initialize_current_model = Promise.async(function(opts)
opts = opts or {}

if opts.restore_from_messages and state.messages then
for i = #state.messages, 1, -1 do
-- Child sessions scan forward (first message is reliable);
-- parent sessions scan backward (most recent is current choice)
local is_child = state.active_session and state.active_session.parentID ~= nil
local start_idx, end_idx, step = #state.messages, 1, -1
if is_child then
start_idx, end_idx, step = 1, #state.messages, 1
end
for i = start_idx, end_idx, step do
local msg = state.messages[i]
if msg and msg.info and msg.info.modelID and msg.info.providerID then
local model_str = msg.info.providerID .. '/' .. msg.info.modelID
if state.current_model ~= model_str then
state.model.set_model(model_str)
end
if msg.info.mode and state.current_mode ~= msg.info.mode then
local available_agents = config_file.get_opencode_agents():await()
if vim.tbl_contains(available_agents, msg.info.mode) then
local all_agents =
vim.list_extend(config_file.get_opencode_agents():await(), config_file.get_subagents():await())
if vim.tbl_contains(all_agents, msg.info.mode) then
state.model.set_mode(msg.info.mode)
end
end
Expand Down
6 changes: 4 additions & 2 deletions lua/opencode/services/messaging.lua
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ M.send_message = Promise.async(function(prompt, opts)
return false
end

if state.active_session.parentID then
if state.active_session.parentID and config.child_readonly then
return false
end

Expand All @@ -36,7 +36,9 @@ M.send_message = Promise.async(function(prompt, opts)
state.context.set_current_context_config(opts.context)
context.load()
opts.model = opts.model or agent_model.initialize_current_model():await()
opts.agent = opts.agent or state.current_mode or config.default_mode
if opts.agent == nil then
opts.agent = state.current_mode or config.default_mode
end
opts.variant = opts.variant or state.current_variant
local params = {}

Expand Down
57 changes: 55 additions & 2 deletions lua/opencode/services/session_runtime.lua
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ local server_job = require('opencode.server_job')
local input_window = require('opencode.ui.input_window')
local util = require('opencode.util')
local config = require('opencode.config')
local config_file = require('opencode.config_file')
local image_handler = require('opencode.image_handler')
local Promise = require('opencode.promise')
local log = require('opencode.log')
Expand Down Expand Up @@ -45,11 +46,63 @@ M.switch_session = Promise.async(function(session_id)
local selected_session = session.get_by_id(session_id):await()

state.model.clear()
agent_model.ensure_current_mode():await()

-- Child sessions: infer agent type from first assistant message
-- (session.agent may reflect a polluted value from prior incorrect params)
if selected_session and selected_session.parentID then
state.session.set_active(selected_session)
local ok, messages = pcall(function()
return session.get_messages(selected_session):await()
end)
local restored = false
if ok and messages then
local all_agents = vim.list_extend(config_file.get_opencode_agents():await(), config_file.get_subagents():await())
for i = 1, #messages do
local msg = messages[i]
if msg and msg.info and msg.info.mode then
if vim.tbl_contains(all_agents, msg.info.mode) then
state.model.set_mode(msg.info.mode)
if msg.info.providerID and msg.info.modelID then
state.model.set_model(msg.info.providerID .. '/' .. msg.info.modelID)
end
restored = true
break
end
end
end
end
if not restored then
-- Fallback to session.agent, then default_mode
if selected_session.agent then
local all_agents =
vim.list_extend(config_file.get_opencode_agents():await(), config_file.get_subagents():await())
if vim.tbl_contains(all_agents, selected_session.agent) then
state.model.set_mode(selected_session.agent)
else
state.model.set_mode(config.default_mode)
end
else
log.notify('Could not infer agent type for child session', vim.log.levels.WARN)
state.model.set_mode(config.default_mode)
end
end
elseif selected_session and selected_session.agent then
-- Parent session with agent info: restore directly
local available_agents = config_file.get_opencode_agents():await()
if vim.tbl_contains(available_agents, selected_session.agent) then
state.model.set_mode(selected_session.agent)
end
if selected_session.model then
local model_str = selected_session.model.providerID .. '/' .. selected_session.model.id
state.model.set_model(model_str)
end
else
agent_model.ensure_current_mode():await()
end
Comment on lines +52 to +101
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would split this into multiple little helpers as it's a bit hard to follow, there is a lot of nesting.


state.session.set_active(selected_session)
if state.ui.is_visible() then
if selected_session and selected_session.parentID then
if selected_session and selected_session.parentID and config.child_readonly then
if not input_window.is_hidden() then
input_window._hide()
end
Expand Down
3 changes: 3 additions & 0 deletions lua/opencode/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@
---@field time { created: number, updated: number }
---@field id string
---@field parentID string|nil
---@field agent string|nil
---@field model { id: string, providerID: string, variant?: string }|nil
---@field revert? SessionRevertInfo
---@field share? SessionShareInfo

Expand Down Expand Up @@ -367,6 +369,7 @@
---@field logging OpencodeLoggingConfig
---@field debug OpencodeDebugConfig
---@field prompt_guard? fun(mentioned_files: string[]): boolean
---@field child_readonly boolean
---@field hooks OpencodeHooks
---@field quick_chat OpencodeQuickChatConfig

Expand Down
3 changes: 1 addition & 2 deletions lua/opencode/ui/input_window.lua
Original file line number Diff line number Diff line change
Expand Up @@ -591,8 +591,7 @@ end

---Show the input window by recreating it
function M._show()
-- Child sessions must never show the input window
if state.active_session and state.active_session.parentID then
if state.active_session and state.active_session.parentID and config.child_readonly then
return
end

Expand Down
7 changes: 4 additions & 3 deletions lua/opencode/ui/ui.lua
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,8 @@ end
---@param output_buf integer
---@return { input_win: integer, output_win: integer }
local function open_float(input_buf, output_buf)
local output_config, input_config = float_layout.window_configs({ input_buf = input_buf, output_buf = output_buf }, true)
local output_config, input_config =
float_layout.window_configs({ input_buf = input_buf, output_buf = output_buf }, true)
local output_win = float_layout.open_win(output_buf, true, output_config)
local input_win = float_layout.open_win(input_buf, true, input_config)

Expand Down Expand Up @@ -403,7 +404,7 @@ end

---@param opts? { restore_position?: boolean, start_insert?: boolean }
function M.focus_input(opts)
if state.active_session and state.active_session.parentID then
if state.active_session and state.active_session.parentID and config.child_readonly then
return
end

Expand Down Expand Up @@ -561,7 +562,7 @@ function M.toggle_pane()
if state.windows and current_win == state.windows.input_win then
output_window.focus_output(true)
else
if state.active_session and state.active_session.parentID then
if state.active_session and state.active_session.parentID and config.child_readonly then
return
end
input_window.focus_input()
Expand Down
15 changes: 15 additions & 0 deletions tests/unit/input_window_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -573,5 +573,20 @@ describe('input_window', function()
-- _hidden remains true because windows are nil, but the parentID guard was passed
assert.is_true(input_window._hidden)
end)

it('_show() proceeds for child session when child_readonly is false', function()
state.session.set_active({ id = 'child1', parentID = 'parent1' })
local config = require('opencode.config')
local orig_readonly = config.values.child_readonly
config.values.child_readonly = false
input_window._hidden = true

-- _show will early-return due to missing windows, but it should pass the guard
input_window._show()

-- _hidden remains true because windows are nil, but the parentID guard was passed
assert.is_true(input_window._hidden)
config.values.child_readonly = orig_readonly
end)
end)
end)
40 changes: 40 additions & 0 deletions tests/unit/services_agent_model_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ describe('opencode.services.agent_model', function()
state.model.set_mode('plan')

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

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

config_file.get_opencode_agents:revert()
config_file.get_subagents:revert()
end)

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

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

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

config_file.get_opencode_agents:revert()
config_file.get_subagents:revert()
end)

it('rejects switch_to_mode in child session', function()
state.session.set_active({ id = 'child1', parentID = 'parent1' })
state.model.set_mode('build')

stub(config_file, 'get_opencode_agents').returns(Promise.new():resolve({ 'plan', 'build' }))

local promise = agent_model.switch_to_mode('plan')
local success = promise:wait()

assert.is_false(success)
assert.equal('build', state.current_mode)

config_file.get_opencode_agents:revert()
state.session.clear_active()
end)

it('allows switch_to_mode in parent session', function()
state.session.set_active({ id = 'parent1' })
state.store.set('current_mode', nil)
state.store.set('current_model', nil)
state.store.set('user_mode_model_map', {})

stub(config_file, 'get_opencode_agents').returns(Promise.new():resolve({ 'plan', 'build' }))
stub(config_file, 'get_opencode_config').returns(Promise.new():resolve({}))

local promise = agent_model.switch_to_mode('plan')
local success = promise:wait()

assert.is_true(success)
assert.equal('plan', state.current_mode)

config_file.get_opencode_agents:revert()
config_file.get_opencode_config:revert()
state.session.clear_active()
end)
end)
Loading
Loading