From 4a1642f12490d2a24ab326da165f7b78b6212797 Mon Sep 17 00:00:00 2001 From: jensenojs Date: Thu, 28 May 2026 19:06:36 +0800 Subject: [PATCH] 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 --- README.md | 1 + lua/opencode/config.lua | 1 + lua/opencode/services/agent_model.lua | 19 +- lua/opencode/services/messaging.lua | 6 +- lua/opencode/services/session_runtime.lua | 57 +++- lua/opencode/types.lua | 3 + lua/opencode/ui/input_window.lua | 3 +- lua/opencode/ui/ui.lua | 7 +- tests/unit/input_window_spec.lua | 15 + tests/unit/services_agent_model_spec.lua | 40 +++ tests/unit/services_messaging_spec.lua | 102 +++++++ tests/unit/services_session_runtime_spec.lua | 277 ++++++++++++++++++- 12 files changed, 515 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index b8d417fa..3412dbda 100644 --- a/README.md +++ b/README.md @@ -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 = { diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 923c84b4..80088291 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -297,6 +297,7 @@ M.defaults = { }, }, prompt_guard = nil, + child_readonly = true, hooks = { on_file_edited = nil, on_session_loaded = nil, diff --git a/lua/opencode/services/agent_model.lua b/lua/opencode/services/agent_model.lua index b29914fa..1ba78e09 100644 --- a/lua/opencode/services/agent_model.lua +++ b/lua/opencode/services/agent_model.lua @@ -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 @@ -166,7 +171,14 @@ 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 @@ -174,8 +186,9 @@ M.initialize_current_model = Promise.async(function(opts) 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 diff --git a/lua/opencode/services/messaging.lua b/lua/opencode/services/messaging.lua index a8373f0c..ac791ebc 100644 --- a/lua/opencode/services/messaging.lua +++ b/lua/opencode/services/messaging.lua @@ -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 @@ -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 = {} diff --git a/lua/opencode/services/session_runtime.lua b/lua/opencode/services/session_runtime.lua index cc77a86a..4ed95ac9 100644 --- a/lua/opencode/services/session_runtime.lua +++ b/lua/opencode/services/session_runtime.lua @@ -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') @@ -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 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 diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 4b83a9d4..426b6f2a 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -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 @@ -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 diff --git a/lua/opencode/ui/input_window.lua b/lua/opencode/ui/input_window.lua index dec47f93..c9c63937 100644 --- a/lua/opencode/ui/input_window.lua +++ b/lua/opencode/ui/input_window.lua @@ -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 diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index 7fd0856c..56d1dc08 100644 --- a/lua/opencode/ui/ui.lua +++ b/lua/opencode/ui/ui.lua @@ -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) @@ -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 @@ -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() diff --git a/tests/unit/input_window_spec.lua b/tests/unit/input_window_spec.lua index 63070e47..7443d198 100644 --- a/tests/unit/input_window_spec.lua +++ b/tests/unit/input_window_spec.lua @@ -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) diff --git a/tests/unit/services_agent_model_spec.lua b/tests/unit/services_agent_model_spec.lua index 08098af7..8b16f42a 100644 --- a/tests/unit/services_agent_model_spec.lua +++ b/tests/unit/services_agent_model_spec.lua @@ -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({ { @@ -165,6 +166,7 @@ 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() @@ -172,6 +174,7 @@ describe('opencode.services.agent_model', function() 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({ { @@ -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) diff --git a/tests/unit/services_messaging_spec.lua b/tests/unit/services_messaging_spec.lua index d1aa5638..46400724 100644 --- a/tests/unit/services_messaging_spec.lua +++ b/tests/unit/services_messaging_spec.lua @@ -144,6 +144,108 @@ describe('opencode.services.messaging', function() state.api_client.create_message = orig end) + it('sends message to child session when child_readonly is false', function() + state.ui.set_windows({ mock = 'windows' }) + 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 + + stub(config_file, 'get_opencode_agents').returns(Promise.new():resolve({ 'build' })) + + local create_called = false + local orig = state.api_client.create_message + state.api_client.create_message = function(_, sid, params) + create_called = true + return Promise.new():resolve({ id = 'm1' }) + end + + messaging.send_message('hello world') + vim.wait(50, function() + return create_called + end) + assert.is_true(create_called) + state.api_client.create_message = orig + config.values.child_readonly = orig_readonly + config_file.get_opencode_agents:revert() + end) + + it('sends inferred agent for child session', function() + state.ui.set_windows({ mock = 'windows' }) + state.model.set_mode('study') -- set by switch_session inference + 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 + + local captured_params = nil + local orig = state.api_client.create_message + state.api_client.create_message = function(_, sid, params) + captured_params = params + return Promise.new():resolve({ id = 'm1' }) + end + + messaging.send_message('hello world') + vim.wait(50, function() + return captured_params ~= nil + end) + + assert.equal('study', captured_params.agent) + state.api_client.create_message = orig + config.values.child_readonly = orig_readonly + end) + + it('respects explicit agent for child session', function() + state.ui.set_windows({ mock = 'windows' }) + 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 + + stub(config_file, 'get_opencode_agents').returns(Promise.new():resolve({ 'study', 'build' })) + + local captured_params = nil + local orig = state.api_client.create_message + state.api_client.create_message = function(_, sid, params) + captured_params = params + return Promise.new():resolve({ id = 'm1' }) + end + + messaging.send_message('hello world', { agent = 'study' }) + vim.wait(50, function() + return captured_params ~= nil + end) + + assert.equal('study', captured_params.agent) + state.api_client.create_message = orig + config.values.child_readonly = orig_readonly + config_file.get_opencode_agents:revert() + end) + + it('sends agent param for parent session', function() + state.ui.set_windows({ mock = 'windows' }) + state.model.set_mode('build') + state.session.set_active({ id = 'sess1' }) + + stub(config_file, 'get_opencode_agents').returns(Promise.new():resolve({ 'build' })) + + local captured_params = nil + local orig = state.api_client.create_message + state.api_client.create_message = function(_, sid, params) + captured_params = params + return Promise.new():resolve({ id = 'm1' }) + end + + messaging.send_message('hello world') + vim.wait(50, function() + return captured_params ~= nil + end) + + assert.equal('build', captured_params.agent) + state.api_client.create_message = orig + config_file.get_opencode_agents:revert() + end) + it('increments and decrements user_message_count correctly', function() state.ui.set_windows({ mock = 'windows' }) state.session.set_active({ id = 'sess1' }) diff --git a/tests/unit/services_session_runtime_spec.lua b/tests/unit/services_session_runtime_spec.lua index 258e1bc1..7d69cba7 100644 --- a/tests/unit/services_session_runtime_spec.lua +++ b/tests/unit/services_session_runtime_spec.lua @@ -339,7 +339,9 @@ describe('opencode.services.session_runtime', function() it('hides input window when switching to a child session', function() state.ui.set_windows({ mock = 'windows', input_buf = 1, output_buf = 2, input_win = 3, output_win = 4 }) local orig_is_visible = state.ui.is_visible - state.ui.is_visible = function() return true end + state.ui.is_visible = function() + return true + end stub(input_window, 'is_hidden').returns(false) stub(input_window, '_hide') @@ -361,7 +363,9 @@ describe('opencode.services.session_runtime', function() it('shows input window when switching to a non-child session', function() state.ui.set_windows({ mock = 'windows', input_buf = 1, output_buf = 2, input_win = 3, output_win = 4 }) local orig_is_visible = state.ui.is_visible - state.ui.is_visible = function() return true end + state.ui.is_visible = function() + return true + end stub(input_window, 'is_hidden').returns(true) stub(input_window, '_show') @@ -378,7 +382,9 @@ describe('opencode.services.session_runtime', function() it('does not hide input when already hidden on child session switch', function() state.ui.set_windows({ mock = 'windows', input_buf = 1, output_buf = 2, input_win = 3, output_win = 4 }) local orig_is_visible = state.ui.is_visible - state.ui.is_visible = function() return true end + state.ui.is_visible = function() + return true + end stub(input_window, 'is_hidden').returns(true) stub(input_window, '_hide') @@ -396,6 +402,185 @@ describe('opencode.services.session_runtime', function() input_window._hide:revert() state.ui.is_visible = orig_is_visible end) + + it('infers mode from first assistant message when switching to child session', function() + -- session.agent may be polluted by previous incorrect agent params, + -- so we always infer from the first assistant message which was set + -- by the task tool at creation time. + session.get_by_id:revert() + stub(session, 'get_by_id').invokes(function(id) + return Promise.new():resolve({ + id = id, + title = 'child session', + modified = os.time(), + parentID = 'parent1', + agent = 'orchestrator', -- polluted by previous incorrect agent param + }) + end) + stub(session, 'get_messages').invokes(function() + return Promise.new():resolve({ + { + info = { + id = 'm1', + providerID = 'anthropic', + modelID = 'claude-3-opus', + mode = 'study', -- correct subagent type from task tool + }, + }, + { + info = { + id = 'm2', + providerID = 'anthropic', + modelID = 'claude-3-opus', + mode = 'orchestrator', -- polluted by incorrect agent param + }, + }, + }) + end) + stub(config_file, 'get_opencode_agents').returns( + Promise.new():resolve({ 'plan', 'build' }) + ) + stub(config_file, 'get_subagents').returns( + Promise.new():resolve({ 'study', 'orchestrator' }) + ) + + session_runtime.switch_session('child1'):wait() + + assert.equal('study', state.current_mode) + + config_file.get_opencode_agents:revert() + config_file.get_subagents:revert() + session.get_messages:revert() + end) + + it('infers mode from message history when session.agent is nil', function() + session.get_by_id:revert() + stub(session, 'get_by_id').invokes(function(id) + return Promise.new():resolve({ + id = id, + title = 'child session', + modified = os.time(), + parentID = 'parent1', + agent = nil, + }) + end) + stub(session, 'get_messages').invokes(function() + return Promise.new():resolve({ + { + info = { + id = 'm1', + providerID = 'anthropic', + modelID = 'claude-3-opus', + mode = 'study', + }, + }, + }) + end) + stub(config_file, 'get_opencode_agents').returns(Promise.new():resolve({ 'plan', 'build' })) + stub(config_file, 'get_subagents').returns(Promise.new():resolve({ 'study' })) + + session_runtime.switch_session('child1'):wait() + + assert.equal('study', state.current_mode) + + config_file.get_opencode_agents:revert() + config_file.get_subagents:revert() + session.get_messages:revert() + end) + + it('falls back to session.agent when message history has no valid mode', function() + session.get_by_id:revert() + stub(session, 'get_by_id').invokes(function(id) + return Promise.new():resolve({ + id = id, + title = 'child session', + modified = os.time(), + parentID = 'parent1', + agent = 'study', + }) + end) + stub(session, 'get_messages').invokes(function() + return Promise.new():resolve({}) -- no messages to infer from + end) + stub(config_file, 'get_opencode_agents').returns(Promise.new():resolve({ 'plan', 'build' })) + stub(config_file, 'get_subagents').returns(Promise.new():resolve({ 'study' })) + + session_runtime.switch_session('child1'):wait() + + assert.equal('study', state.current_mode) + + config_file.get_opencode_agents:revert() + config_file.get_subagents:revert() + session.get_messages:revert() + end) + + it('falls back to default_mode when child session has no agent and no messages', function() + local config = require('opencode.config') + local orig_default = config.default_mode + config.default_mode = 'build' + + session.get_by_id:revert() + stub(session, 'get_by_id').invokes(function(id) + return Promise.new():resolve({ + id = id, + title = 'child session', + modified = os.time(), + parentID = 'parent1', + agent = nil, + }) + end) + stub(session, 'get_messages').invokes(function() + return Promise.new():resolve({}) + end) + + session_runtime.switch_session('child1'):wait() + + assert.equal('build', state.current_mode) + + config.default_mode = orig_default + session.get_messages:revert() + end) + + it('skips invalid mode and falls back when subagent was removed from config', function() + local config = require('opencode.config') + local orig_default = config.default_mode + config.default_mode = 'build' + + session.get_by_id:revert() + stub(session, 'get_by_id').invokes(function(id) + return Promise.new():resolve({ + id = id, + title = 'child session', + modified = os.time(), + parentID = 'parent1', + agent = nil, + }) + end) + stub(session, 'get_messages').invokes(function() + return Promise.new():resolve({ + { + info = { + id = 'm1', + providerID = 'anthropic', + modelID = 'claude-3-opus', + mode = 'deleted_subagent', -- no longer exists in config + }, + }, + }) + end) + stub(config_file, 'get_opencode_agents').returns(Promise.new():resolve({ 'plan', 'build' })) + stub(config_file, 'get_subagents').returns(Promise.new():resolve({ 'study' })) + -- 'deleted_subagent' is not in either list + + session_runtime.switch_session('child1'):wait() + + assert.equal('build', state.current_mode) -- falls back to default_mode + + config.default_mode = orig_default + config_file.get_opencode_agents:revert() + config_file.get_subagents:revert() + session.get_messages:revert() + end) end) describe('child session UI guards', function() @@ -410,7 +595,12 @@ describe('opencode.services.session_runtime', function() stub(input_window, 'focus_input') -- Simulate being in the output window (not input) - state.ui.set_windows({ input_win = -1, output_win = vim.api.nvim_get_current_win(), input_buf = 1, output_buf = 2 }) + state.ui.set_windows({ + input_win = -1, + output_win = vim.api.nvim_get_current_win(), + input_buf = 1, + output_buf = 2, + }) ui.toggle_pane() @@ -429,6 +619,85 @@ describe('opencode.services.session_runtime', function() input_window.is_hidden:revert() input_window._show:revert() end) + + it('toggle_pane shows input 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 + stub(input_window, 'focus_input') + + state.ui.set_windows({ + input_win = -1, + output_win = vim.api.nvim_get_current_win(), + input_buf = 1, + output_buf = 2, + }) + + ui.toggle_pane() + + assert.stub(input_window.focus_input).was_called() + input_window.focus_input:revert() + config.values.child_readonly = orig_readonly + end) + + it('focus_input works when child_readonly is false', function() + state.ui.set_windows({ mock = 'windows', input_buf = 1, output_buf = 2 }) + 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 + + -- Revert the before_each stub so we call the real focus_input + ui.focus_input:revert() + + local reached_is_hidden = false + local orig_is_hidden = input_window.is_hidden + input_window.is_hidden = function() + reached_is_hidden = true + return true + end + local orig_show = input_window._show + input_window._show = function() end + + ui.focus_input() + + assert.is_true(reached_is_hidden) + input_window.is_hidden = orig_is_hidden + input_window._show = orig_show + config.values.child_readonly = orig_readonly + -- Re-stub for after_each cleanup + stub(ui, 'focus_input') + end) + + it('switch_session does not hide input when child_readonly is false', function() + state.ui.set_windows({ mock = 'windows', input_buf = 1, output_buf = 2, input_win = 3, output_win = 4 }) + local orig_is_visible = state.ui.is_visible + state.ui.is_visible = function() + return true + end + local config = require('opencode.config') + local orig_readonly = config.values.child_readonly + config.values.child_readonly = false + + stub(input_window, 'is_hidden').returns(false) + stub(input_window, '_hide') + + session.get_by_id:revert() + stub(session, 'get_by_id').invokes(function(id) + return Promise.new():resolve({ id = id, title = id, modified = os.time(), parentID = 'parent1' }) + end) + + session_runtime.switch_session('child1'):wait() + + assert.stub(input_window._hide).was_not_called() + assert.stub(ui.focus_input).was_called() + + input_window.is_hidden:revert() + input_window._hide:revert() + state.ui.is_visible = orig_is_visible + config.values.child_readonly = orig_readonly + end) end) describe('send_message', function()