diff --git a/lua/opencode/commands/handlers/AGENTS.md b/lua/opencode/commands/handlers/AGENTS.md index 33f01641..01b74081 100644 --- a/lua/opencode/commands/handlers/AGENTS.md +++ b/lua/opencode/commands/handlers/AGENTS.md @@ -1,13 +1,23 @@ # AGENTS.md (handlers) -This directory owns domain actions and command definitions. +This directory owns command-facing action adapters and command definitions. + +One-line positioning: +- `handlers/` = **command-entry adapters** +- `services/` = **cross-entry reusable business primitives** ## Scope -- `M.actions`: domain operations +- `M.actions`: command-facing action adapters - `M.command_defs`: command-facing definitions (desc/completions/execute) - No command pipeline logic here +## Relation with services + +- Handlers should call `services/*` when logic is shared by command/UI/quick_chat entries. +- Handlers should not become the only place for reusable business logic. +- If an action is needed outside command entry paths, move/keep it in `services/`. + ## Hard Invariants - Handlers do not call `dispatch.execute` directly @@ -15,6 +25,7 @@ This directory owns domain actions and command definitions. - Handlers do not decide hook routing - Keep action behavior identical across entry points - Handlers must not introduce any new bind/execute entry symbols (`*.run`, `bind_*`, or dispatch wrappers) +- Prefer `services/*` over direct new `opencode.session` / `opencode.api` requires in handlers ## Structure Guidance @@ -22,6 +33,17 @@ This directory owns domain actions and command definitions. - Keep keymap compatibility aliases explicit and grouped - Avoid duplicating command validation already guaranteed by parse schema +## Current boundary debt (file-level TODO) + +The following handler files still directly require `opencode.session` and should be routed via services APIs. + +- [ ] `lua/opencode/commands/handlers/diff.lua` -> `opencode.session` +- [ ] `lua/opencode/commands/handlers/session.lua` -> `opencode.session` + +Sync rule: +- keep this list aligned with `lua/opencode/services/AGENTS.md` +- remove an item only after code + tests pass + ## Editing Rules - Prefer consolidation over introducing new layers @@ -46,12 +68,14 @@ This directory owns domain actions and command definitions. - Is `command_defs` declarative and minimal? - Are aliases grouped and obvious? - Did we avoid reintroducing duplicate argument validation paths? +- Did we add any new direct `session/api` requires in handlers? ## Reject Conditions - Any direct call to `dispatch.execute` from handlers - Any parse/hook routing logic added to handlers - Any new entry-style wrapper added in handlers +- Any new direct `require('opencode.session')` or `require('opencode.api')` in handlers without exception note ## Minimal Regression Commands @@ -63,6 +87,6 @@ This directory owns domain actions and command definitions. ## Entry Notes For New Agents - Start from the domain file you are touching (`window/session/diff/workflow/surface/agent/permission`), then verify invariants against `commands/dispatch.lua`. -- Treat handlers as **domain behavior + command definition only**. +- Treat handlers as **command adaptation + command definition only**. - If you feel the need to touch parse/dispatch from handlers, stop and move that change to the command infrastructure layer. - Keep compatibility aliases explicit, local, and justified inline. diff --git a/lua/opencode/commands/handlers/agent.lua b/lua/opencode/commands/handlers/agent.lua index 85096061..01adc6c2 100644 --- a/lua/opencode/commands/handlers/agent.lua +++ b/lua/opencode/commands/handlers/agent.lua @@ -1,9 +1,9 @@ -local core = require('opencode.core') local config_file = require('opencode.config_file') ---@type OpencodeState local state = require('opencode.state') local util = require('opencode.util') local Promise = require('opencode.promise') +local agent_model = require('opencode.services.agent_model') local M = { actions = {}, @@ -18,23 +18,23 @@ local function invalid_arguments(message) end function M.actions.configure_provider() - core.configure_provider() + agent_model.configure_provider() end function M.actions.configure_variant() - core.configure_variant() + agent_model.configure_variant() end function M.actions.cycle_variant() - core.cycle_variant() + agent_model.cycle_variant() end function M.actions.agent_plan() - core.switch_to_mode('plan') + agent_model.switch_to_mode('plan') end function M.actions.agent_build() - core.switch_to_mode('build') + agent_model.switch_to_mode('build') end M.actions.select_agent = Promise.async(function() @@ -47,7 +47,7 @@ M.actions.select_agent = Promise.async(function() return end - core.switch_to_mode(selection) + agent_model.switch_to_mode(selection) end) end) @@ -60,11 +60,11 @@ M.actions.switch_mode = Promise.async(function() end local next_index = (current_index % #modes) + 1 - core.switch_to_mode(modes[next_index]) + agent_model.switch_to_mode(modes[next_index]) end) M.actions.current_model = Promise.async(function() - return core.initialize_current_model() + return agent_model.initialize_current_model() end) local agent_subcommands = { 'plan', 'build', 'select' } diff --git a/lua/opencode/commands/handlers/diff.lua b/lua/opencode/commands/handlers/diff.lua index 9e2286d5..63941080 100644 --- a/lua/opencode/commands/handlers/diff.lua +++ b/lua/opencode/commands/handlers/diff.lua @@ -1,8 +1,8 @@ -local core = require('opencode.core') local git_review = require('opencode.git_review') local session_store = require('opencode.session') ---@type OpencodeState local state = require('opencode.state') +local session_runtime = require('opencode.services.session_runtime') local M = { actions = {}, @@ -11,7 +11,7 @@ local M = { local diff_subcommands = { 'open', 'next', 'prev', 'close' } local function with_output_open(callback, open_if_closed) - local open_fn = open_if_closed and core.open_if_closed or core.open + local open_fn = open_if_closed and session_runtime.open_if_closed or session_runtime.open return function(...) local args = { ... } open_fn({ new_session = false, focus = 'output' }):and_then(function() diff --git a/lua/opencode/commands/handlers/session.lua b/lua/opencode/commands/handlers/session.lua index fa85d3af..0969b46c 100644 --- a/lua/opencode/commands/handlers/session.lua +++ b/lua/opencode/commands/handlers/session.lua @@ -1,9 +1,10 @@ -local core = require('opencode.core') ---@type OpencodeState local state = require('opencode.state') local session_store = require('opencode.session') local Promise = require('opencode.promise') local window_actions = require('opencode.commands.handlers.window').actions +local session_runtime = require('opencode.services.session_runtime') +local agent_model = require('opencode.services.agent_model') local M = { actions = {}, @@ -77,13 +78,13 @@ local function run_api_action_with_checktime(request_promise, error_prefix) end function M.actions.open_input_new_session() - return core.open({ new_session = true, focus = 'input', start_insert = true }) + return session_runtime.open({ new_session = true, focus = 'input', start_insert = true }) end ---@param title string function M.actions.open_input_new_session_with_title(title) return Promise.async(function(session_title) - local new_session = core.create_new_session(session_title):await() + local new_session = session_runtime.create_new_session(session_title):await() if not new_session then vim.notify('Failed to create new session', vim.log.levels.ERROR) return @@ -96,12 +97,12 @@ end ---@param parent_id? string function M.actions.select_session(parent_id) - core.select_session(parent_id) + session_runtime.select_session(parent_id) end function M.actions.select_child_session() local active = state.active_session - core.select_session(active and active.id or nil) + session_runtime.select_session(active and active.id or nil) end ---@param current_session? Session @@ -162,15 +163,14 @@ function M.actions.initialize() return Promise.async(function() local id = require('opencode.id') local state_obj = state - local core_obj = core - local new_session = core_obj.create_new_session('AGENTS.md Initialization'):await() + local new_session = session_runtime.create_new_session('AGENTS.md Initialization'):await() if not new_session then vim.notify('Failed to create new session', vim.log.levels.ERROR) return end - if not core_obj.initialize_current_model():await() or not state_obj.current_model then + if not agent_model.initialize_current_model():await() or not state_obj.current_model then vim.notify('No model selected', vim.log.levels.ERROR) return end @@ -369,7 +369,7 @@ function M.actions.fork_session(message_id) vim.schedule(function() if response and response.id then vim.notify('Session forked successfully. New session ID: ' .. response.id, vim.log.levels.INFO) - core.switch_session(response.id) + session_runtime.switch_session(response.id) else vim.notify('Session forked but no new session ID received', vim.log.levels.WARN) end diff --git a/lua/opencode/commands/handlers/window.lua b/lua/opencode/commands/handlers/window.lua index 188a29f2..cf7c00de 100644 --- a/lua/opencode/commands/handlers/window.lua +++ b/lua/opencode/commands/handlers/window.lua @@ -1,10 +1,10 @@ -local core = require('opencode.core') ---@type OpencodeState local state = require('opencode.state') local ui = require('opencode.ui.ui') local config = require('opencode.config') local Promise = require('opencode.promise') local input_window = require('opencode.ui.input_window') +local session_runtime = require('opencode.services.session_runtime') local M = { actions = {}, @@ -19,11 +19,11 @@ local function invalid_arguments(message) end function M.actions.open_input() - return core.open({ new_session = false, focus = 'input', start_insert = true }) + return session_runtime.open({ new_session = false, focus = 'input', start_insert = true }) end function M.actions.open_output() - return core.open({ new_session = false, focus = 'output' }) + return session_runtime.open({ new_session = false, focus = 'output' }) end function M.actions.close() @@ -47,7 +47,7 @@ function M.actions.get_window_state() end function M.actions.cancel() - core.cancel() + session_runtime.cancel() end ---@param hidden OpencodeHiddenBuffers|nil @@ -90,8 +90,8 @@ M.actions.toggle = Promise.async(function(new_session) local function open_windows(restore_hidden) local ctx = build_toggle_open_context(restore_hidden == true) - return core - .open({ + return session_runtime + .open({ new_session = is_new_session, focus = ctx.focus, start_insert = false, @@ -132,7 +132,7 @@ end) function M.actions.toggle_focus(new_session) if not ui.is_opencode_focused() then local focus = state.last_focused_opencode_window or 'input' ---@cast focus 'input' | 'output' - core.open({ new_session = new_session == true, focus = focus }) + session_runtime.open({ new_session = new_session == true, focus = focus }) else ui.return_to_last_code_win() end diff --git a/lua/opencode/commands/handlers/workflow.lua b/lua/opencode/commands/handlers/workflow.lua index 834a4eeb..2853fa93 100644 --- a/lua/opencode/commands/handlers/workflow.lua +++ b/lua/opencode/commands/handlers/workflow.lua @@ -1,4 +1,3 @@ -local core = require('opencode.core') local util = require('opencode.util') local config_file = require('opencode.config_file') ---@type OpencodeState @@ -11,6 +10,7 @@ local Promise = require('opencode.promise') local input_window = require('opencode.ui.input_window') local ui = require('opencode.ui.ui') local nvim = vim['api'] +local session_runtime = require('opencode.services.session_runtime') local M = { actions = {}, @@ -49,8 +49,8 @@ end ---@param prompt string ---@param opts SendMessageOpts local function run_with_opts(prompt, opts) - return core.open(opts):and_then(function() - return core.send_message(prompt, opts) + return session_runtime.open(opts):and_then(function() + return require('opencode.services.messaging').send_message(prompt, opts) end) end @@ -189,7 +189,7 @@ for _, action_name in ipairs({ 'debug_output', 'debug_message', 'debug_session' end function M.actions.paste_image() - core.paste_image_from_clipboard() + session_runtime.paste_image_from_clipboard() end M.actions.submit_input_prompt = Promise.async(function() @@ -301,12 +301,12 @@ function M.actions.toggle_max_messages() end M.actions.review = Promise.async(function(args) - local new_session = core.create_new_session('Code review checklist for diffs and PRs'):await() + local new_session = session_runtime.create_new_session('Code review checklist for diffs and PRs'):await() if not new_session then vim.notify('Failed to create new session', vim.log.levels.ERROR) return end - if not core.initialize_current_model():await() or not state.current_model then + if not require('opencode.services.agent_model').initialize_current_model():await() or not state.current_model then vim.notify('No model selected', vim.log.levels.ERROR) return end diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua deleted file mode 100644 index e468f3a6..00000000 --- a/lua/opencode/core.lua +++ /dev/null @@ -1,647 +0,0 @@ -local state = require('opencode.state') -local context = require('opencode.context') -local session = require('opencode.session') -local ui = require('opencode.ui.ui') -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 image_handler = require('opencode.image_handler') -local Promise = require('opencode.promise') -local permission_window = require('opencode.ui.permission_window') -local log = require('opencode.log') - -local M = {} -M._abort_count = 0 - ----@param parent_id string? -M.select_session = Promise.async(function(parent_id) - local all_sessions = session.get_all_workspace_sessions():await() or {} - ---@cast all_sessions Session[] - - local filtered_sessions = vim.tbl_filter(function(s) - return s.title ~= '' and s ~= nil and s.parentID == parent_id - end, all_sessions) - - if #filtered_sessions == 0 then - vim.notify(parent_id and 'No child sessions found' or 'No sessions found', vim.log.levels.INFO) - if state.ui.is_visible() then - ui.focus_input() - end - return - end - - ui.select_session(filtered_sessions, function(selected_session) - if not selected_session then - if state.ui.is_visible() then - ui.focus_input() - end - return - end - M.switch_session(selected_session.id) - end) -end) - -M.switch_session = Promise.async(function(session_id) - local selected_session = session.get_by_id(session_id):await() - - state.model.clear() - M.ensure_current_mode():await() - - state.session.set_active(selected_session) - if state.ui.is_visible() then - ui.focus_input() - else - M.open() - end -end) - ----@param opts? OpenOpts -M.open_if_closed = Promise.async(function(opts) - if not state.ui.is_visible() then - M.open(opts):await() - end -end) - -M.is_prompting_allowed = function() - local mentioned_files = context.get_context().mentioned_files or {} - local allowed, err_msg = util.check_prompt_allowed(config.prompt_guard, mentioned_files) - if not allowed then - vim.notify(err_msg or 'Prompt denied by prompt_guard', vim.log.levels.ERROR) - end - return allowed -end - -M.check_cwd = function() - if state.current_cwd ~= vim.fn.getcwd() then - log.debug( - 'CWD changed since last check, resetting session and context', - { current_cwd = state.current_cwd, new_cwd = vim.fn.getcwd() } - ) - state.context.set_current_cwd(vim.fn.getcwd()) - state.session.clear_active() - context.unload_attachments() - end -end - ----@param opts? OpenOpts -M.open = Promise.async(function(opts) - opts = opts or { focus = 'input', new_session = false } - - state.ui.set_opening(true) - - if not require('opencode.ui.ui').is_opencode_focused() then - require('opencode.context').load() - end - - local open_windows_action = opts.open_action or state.ui.resolve_open_windows_action() - local are_windows_closed = open_windows_action ~= 'reuse_visible' - local restoring_hidden = open_windows_action == 'restore_hidden' - - if are_windows_closed then - if not ui.is_opencode_focused() then - state.ui.set_code_context(vim.api.nvim_get_current_win(), vim.api.nvim_get_current_buf()) - end - - M.is_prompting_allowed() - - if restoring_hidden then - local restored = ui.restore_hidden_windows() - if not restored then - state.ui.clear_hidden_window_state() - restoring_hidden = false - state.ui.set_windows(ui.create_windows()) - end - else - state.ui.set_windows(ui.create_windows()) - end - end - - if opts.focus == 'input' then - ui.focus_input({ restore_position = are_windows_closed, start_insert = opts.start_insert == true }) - elseif opts.focus == 'output' then - ui.focus_output({ restore_position = are_windows_closed }) - end - - local server = server_job.ensure_server():await() - - if not server then - state.ui.set_opening(false) - return Promise.new():reject('Server failed to start') - end - - M.check_cwd() - - local ok, err = pcall(function() - if opts.new_session then - state.session.clear_active() - context.unload_attachments() - - M.ensure_current_mode():await() - - state.session.set_active(M.create_new_session():await()) - log.debug('Created new session on open', { session = state.active_session.id }) - else - M.ensure_current_mode():await() - - if not state.active_session then - state.session.set_active(session.get_last_workspace_session():await()) - if not state.active_session then - state.session.set_active(M.create_new_session():await()) - end - else - if not state.display_route and are_windows_closed and not restoring_hidden then - -- We're not displaying /help or something like that but we have an active session - -- and the windows were closed so we need to do a full refresh. This mostly happens - -- when opening the window after having closed it since we're not currently clearing - -- the session on api.close() - ui.render_output() - end - end - end - - state.ui.set_panel_focused(true) - end) - - state.ui.set_opening(false) - - if not ok then - vim.notify('Error opening panel: ' .. tostring(err), vim.log.levels.ERROR) - return Promise.new():reject(err) - end - return Promise.new():resolve('ok') -end) - ---- Sends a message to the active session, creating one if necessary. ---- @param prompt string The message prompt to send. ---- @param opts? SendMessageOpts -M.send_message = Promise.async(function(prompt, opts) - if not state.active_session or not state.active_session.id then - return false - end - - local mentioned_files = context.get_context().mentioned_files or {} - local allowed, err_msg = util.check_prompt_allowed(config.prompt_guard, mentioned_files) - - if not allowed then - vim.notify(err_msg or 'Prompt denied by prompt_guard', vim.log.levels.ERROR) - return - end - - opts = opts or {} - - opts.context = vim.tbl_deep_extend('force', state.current_context_config or {}, opts.context or {}) - state.context.set_current_context_config(opts.context) - context.load() - opts.model = opts.model or M.initialize_current_model():await() - opts.agent = opts.agent or state.current_mode or config.default_mode - opts.variant = opts.variant or state.current_variant - local params = {} - - if opts.model then - local provider, model = opts.model:match('^(.-)/(.+)$') - params.model = { providerID = provider, modelID = model } - state.model.set_model(opts.model) - - if opts.variant then - params.variant = opts.variant - state.model.set_variant(opts.variant) - end - end - - if opts.agent then - params.agent = opts.agent - state.model.set_mode(opts.agent) - end - - params.parts = context.format_message(prompt, opts.context):await() - params.system = opts.system or config.default_system_prompt or nil - - M.before_run(opts) - - local session_id = state.active_session.id - - ---Helper to update state.user_message_count. Have to deepcopy since it's a table to make - ---sure notification events fire. Prevents negative values (in case of an untracked code path) - local function update_sent_message_count(num) - local sent_message_count = vim.deepcopy(state.user_message_count) - local new_value = (sent_message_count[session_id] or 0) + num - sent_message_count[session_id] = new_value >= 0 and new_value or 0 - state.session.set_user_message_count(sent_message_count) - end - - update_sent_message_count(1) - - state.api_client - :create_message(session_id, params) - :and_then(function(response) - update_sent_message_count(-1) - - if not response or not response.info or not response.parts then - vim.notify('Invalid response from opencode: ' .. vim.inspect(response), vim.log.levels.ERROR) - M.cancel() - return - end - - M.after_run(prompt) - end) - :catch(function(err) - vim.notify('Error sending message to session: ' .. vim.inspect(err), vim.log.levels.ERROR) - update_sent_message_count(-1) - M.cancel() - end) -end) - ----@param title? string ----@return Session? -M.create_new_session = Promise.async(function(title) - local session_response = state.api_client - :create_session(title and { title = title } or false) - :catch(function(err) - vim.notify('Error creating new session: ' .. vim.inspect(err), vim.log.levels.ERROR) - end) - :await() - - if session_response and session_response.id then - local new_session = session.get_by_id(session_response.id):await() - return new_session - end -end) - ----@param prompt string -function M.after_run(prompt) - context.unload_attachments() - state.session.set_last_sent_context(vim.deepcopy(context.get_context())) - context.delta_context() - require('opencode.history').write(prompt) - M._abort_count = 0 -end - ----@param opts? SendMessageOpts -function M.before_run(opts) - local is_new_session = opts and opts.new_session or not state.active_session - opts = opts or {} - - M.open({ - new_session = is_new_session, - }) -end - -function M.configure_provider() - require('opencode.model_picker').select(function(selection) - if not selection then - if state.ui.is_visible() then - ui.focus_input() - end - return - end - local model_str = string.format('%s/%s', selection.provider, selection.model) - state.model.set_model(model_str) - - if state.current_mode then - state.model.set_mode_model_override(state.current_mode, model_str) - end - - if state.ui.is_visible() then - ui.focus_input() - else - vim.notify('Changed provider to ' .. model_str, vim.log.levels.INFO) - end - end) -end - -function M.configure_variant() - require('opencode.variant_picker').select(function(selection) - if not selection then - if state.ui.is_visible() then - ui.focus_input() - end - return - end - - state.model.set_variant(selection.name) - - if state.ui.is_visible() then - ui.focus_input() - else - vim.notify('Changed variant to ' .. selection.name, vim.log.levels.INFO) - end - end) -end - -M.cycle_variant = Promise.async(function() - if not state.current_model then - vim.notify('No model selected', vim.log.levels.WARN) - return - end - - local provider, model = state.current_model:match('^(.-)/(.+)$') - if not provider or not model then - return - end - - local config_file = require('opencode.config_file') - local model_info = config_file.get_model_info(provider, model) - - if not model_info or not model_info.variants then - vim.notify('Current model does not support variants', vim.log.levels.WARN) - return - end - - local variants = {} - for variant_name, _ in pairs(model_info.variants) do - table.insert(variants, variant_name) - end - - util.sort_by_priority(variants, function(item) - return item - end, { low = 1, medium = 2, high = 3 }) - - if #variants == 0 then - return - end - - local total_count = #variants + 1 - - local current_index - if state.current_variant == nil then - current_index = total_count - else - current_index = util.index_of(variants, state.current_variant) or 0 - end - - local next_index = (current_index % total_count) + 1 - - local next_variant - if next_index > #variants then - next_variant = nil - else - next_variant = variants[next_index] - end - - state.model.set_variant(next_variant) - - local model_state = require('opencode.model_state') - model_state.set_variant(provider, model, next_variant) -end) - -M.cancel = Promise.async(function() - if state.active_session and state.jobs.is_running() then - M._abort_count = M._abort_count + 1 - - local permissions = state.pending_permissions or {} - if #permissions and state.api_client then - for _, permission in ipairs(permissions) do - require('opencode.api').permission_deny(permission) - end - end - - local ok, result = pcall(function() - return state.api_client:abort_session(state.active_session.id):wait() - end) - - if not ok then - vim.notify('Abort error: ' .. vim.inspect(result)) - end - - if M._abort_count >= 3 then - vim.notify('Re-starting Opencode server') - M._abort_count = 0 - -- close existing server - if state.opencode_server then - state.opencode_server:shutdown():await() - end - - -- start a new one - state.jobs.clear_server() - - -- NOTE: start a new server here to make sure we're subscribed - -- to server events before a user sends a message - state.jobs.set_server(server_job.ensure_server():await() --[[@as OpencodeServer]]) - end - end - - if state.ui.is_visible() then - require('opencode.ui.footer').clear() - input_window.set_content('') - require('opencode.history').index = nil - ui.focus_input() - end -end) - -M.opencode_ok = Promise.async(function() - if vim.fn.executable(config.opencode_executable) == 0 then - vim.notify( - 'opencode command not found - please install and configure opencode before using this plugin', - vim.log.levels.ERROR - ) - return false - end - - if not state.opencode_cli_version or state.opencode_cli_version == '' then - local result = Promise.system({ config.opencode_executable, '--version' }):await() - local out = (result and result.stdout or ''):gsub('%s+$', '') - state.jobs.set_opencode_cli_version(out:match('(%d+%%.%d+%%.%d+)') or out) - end - - local required = state.required_version - local current_version = state.opencode_cli_version - - if not current_version or current_version == '' then - vim.notify(string.format('Unable to detect opencode CLI version. Requires >= %s', required), vim.log.levels.ERROR) - return false - end - - if not util.is_version_greater_or_equal(current_version, required) then - vim.notify( - string.format('Unsupported opencode CLI version: %s. Requires >= %s', current_version, required), - vim.log.levels.ERROR - ) - return false - end - - return true -end) - -local function on_opencode_server() - permission_window.clear_all() -end - ---- Switches the current mode to the specified agent. ---- @param mode string|nil The agent/mode to switch to ---- @return boolean success Returns true if the mode was switched successfully, false otherwise -M.switch_to_mode = Promise.async(function(mode) - if not mode or mode == '' then - vim.notify('Mode cannot be empty', vim.log.levels.ERROR) - return false - end - - local config_file = require('opencode.config_file') - local available_agents = config_file.get_opencode_agents():await() - - if not vim.tbl_contains(available_agents, mode) then - vim.notify( - string.format('Invalid mode "%s". Available modes: %s', mode, table.concat(available_agents, ', ')), - vim.log.levels.ERROR - ) - return false - end - - state.model.set_mode(mode) - local opencode_config = config_file.get_opencode_config():await() --[[@as OpencodeConfigFile]] - - local agent_config = opencode_config and opencode_config.agent or {} - local mode_config = agent_config[mode] or {} - - if state.user_mode_model_map[mode] then - state.model.set_model(state.user_mode_model_map[mode]) - elseif mode_config.model and mode_config.model ~= '' then - state.model.set_model(mode_config.model) - elseif opencode_config and opencode_config.model and opencode_config.model ~= '' then - state.model.set_model(opencode_config.model) - end - return true -end) - ---- Ensure the current_mode is set using the config.default_mode or falling back to the first available agent. ---- @return boolean success Returns true if current_mode is set -M.ensure_current_mode = Promise.async(function() - if state.current_mode == nil then - local config_file = require('opencode.config_file') - local available_agents = config_file.get_opencode_agents():await() - - if not available_agents or #available_agents == 0 then - vim.notify('No available agents found', vim.log.levels.ERROR) - return false - end - - local default_mode = config.default_mode - - -- Try to use the configured default mode if it's available - if default_mode and vim.tbl_contains(available_agents, default_mode) then - return M.switch_to_mode(default_mode):await() - else - -- Fallback to first available agent - return M.switch_to_mode(available_agents[1]):await() - end - end - return true -end) - ----@class InitializeCurrentModelOpts ----@field restore_from_messages? boolean Restore model/mode from the most recent session message - ----Initialize current model from session messages or config. ----@param opts? InitializeCurrentModelOpts ----@return string|nil The current model -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 - 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 - state.model.set_mode(msg.info.mode) - end - return state.current_model - end - end - end - - if state.current_model then - return state.current_model - end - - local cfg = require('opencode.config_file').get_opencode_config():await() - if cfg and cfg.model and cfg.model ~= '' then - state.model.set_model(cfg.model) - end - - return state.current_model -end) - -M._on_user_message_count_change = Promise.async(function(_, new, old) - require('opencode.ui.renderer.flush').flush_pending_on_data_rendered() - - if config.hooks and config.hooks.on_done_thinking then - local all_sessions = session.get_all_workspace_sessions():await() - local done_sessions = vim.tbl_filter(function(s) - local msg_count = new[s.id] or 0 - local old_msg_count = (old and old[s.id]) or 0 - return msg_count == 0 and old_msg_count > 0 - end, all_sessions or {}) - - for _, done_session in ipairs(done_sessions) do - pcall(config.hooks.on_done_thinking, done_session) - end - end -end) - -M._on_current_permission_change = Promise.async(function(_, new, old) - local permission_requested = #old < #new - if config.hooks and config.hooks.on_permission_requested and permission_requested then - local local_session = (state.active_session and state.active_session.id) - and session.get_by_id(state.active_session.id):await() - or {} - pcall(config.hooks.on_permission_requested, local_session) - end -end) - ---- Handle clipboard image data by saving it to a file and adding it to context ---- @return boolean success True if image was successfully handled -function M.paste_image_from_clipboard() - return image_handler.paste_image_from_clipboard() -end - ---- Handle working directory changes loading the appropriate session. ---- @return Promise -M.handle_directory_change = Promise.async(function() - local log = require('opencode.log') - - local cwd = vim.fn.getcwd() - log.debug('Working directory change %s', vim.inspect({ cwd = cwd })) - vim.notify('Loading last session for new working dir [' .. cwd .. ']', vim.log.levels.INFO) - - state.session.clear_active() - context.unload_attachments() - - state.session.set_active(session.get_last_workspace_session():await() or M.create_new_session():await()) - - log.debug('Loaded session for new working dir ' .. vim.inspect({ session = state.active_session })) -end) - -function M.setup() - state.store.subscribe('opencode_server', on_opencode_server) - state.store.subscribe('user_message_count', M._on_user_message_count_change) - state.store.subscribe('pending_permissions', M._on_current_permission_change) - state.store.subscribe('current_model', function(key, new_val, old_val) - if new_val ~= old_val then - state.model.clear_variant() - - -- Load saved variant for the new model - if new_val then - local provider, model = new_val:match('^(.-)/(.+)$') - if provider and model then - local model_state = require('opencode.model_state') - local saved_variant = model_state.get_variant(provider, model) - if saved_variant then - state.model.set_variant(saved_variant) - end - end - end - end - end) - - vim.schedule(function() - M.opencode_ok() - end) - local OpencodeApiClient = require('opencode.api_client') - state.jobs.set_api_client(OpencodeApiClient.create()) -end - -return M diff --git a/lua/opencode/init.lua b/lua/opencode/init.lua index dd649d8d..1d873257 100644 --- a/lua/opencode/init.lua +++ b/lua/opencode/init.lua @@ -1,6 +1,37 @@ local M = {} +local setup_done = false +local state + +local session_runtime = require('opencode.services.session_runtime') + +local function on_opencode_server() + require('opencode.ui.permission_window').clear_all() +end + +local function on_current_model_change(_key, new_val, old_val) + if new_val ~= old_val then + state.model.clear_variant() + + if new_val then + local provider, model = new_val:match('^(.-)/(.+)$') + if provider and model then + local model_state = require('opencode.model_state') + local saved_variant = model_state.get_variant(provider, model) + if saved_variant then + state.model.set_variant(saved_variant) + end + end + end + end +end + function M.setup(opts) + if setup_done then + return + end + setup_done = true + -- Have to setup config first, especially before state as -- it initializes at least one value (current_mode) from config. -- If state is require'd first then it will not get what may @@ -9,7 +40,19 @@ function M.setup(opts) config.setup(opts) require('opencode.ui.highlight').setup() - require('opencode.core').setup() + + state = require('opencode.state') + state.store.subscribe('opencode_server', on_opencode_server) + state.store.subscribe('user_message_count', session_runtime._on_user_message_count_change) + state.store.subscribe('pending_permissions', session_runtime._on_current_permission_change) + state.store.subscribe('current_model', on_current_model_change) + + vim.schedule(function() + session_runtime.opencode_ok() + end) + local OpencodeApiClient = require('opencode.api_client') + state.jobs.set_api_client(OpencodeApiClient.create()) + require('opencode.commands').setup() require('opencode.ui.completion').setup() require('opencode.keymap').setup(config.keymap) diff --git a/lua/opencode/quick_chat.lua b/lua/opencode/quick_chat.lua index efb878fe..d46c71dc 100644 --- a/lua/opencode/quick_chat.lua +++ b/lua/opencode/quick_chat.lua @@ -1,11 +1,12 @@ local context = require('opencode.context') local state = require('opencode.state') local config = require('opencode.config') -local core = require('opencode.core') local util = require('opencode.util') local session = require('opencode.session') local Promise = require('opencode.promise') local CursorSpinner = require('opencode.quick_chat.spinner') +local session_runtime = require('opencode.services.session_runtime') +local agent_model = require('opencode.services.agent_model') local M = {} @@ -322,7 +323,7 @@ local create_message = Promise.async(function(message, buf, range, context_confi local params = { parts = parts } - local current_model = core.initialize_current_model():await() + local current_model = agent_model.initialize_current_model():await() local target_model = options.model or quick_chat_config.default_model or current_model if target_model then local provider, model = target_model:match('^(.-)/(.+)$') @@ -368,7 +369,7 @@ M.quick_chat = Promise.async(function(message, options, range) end local title = create_session_title(buf) - local quick_chat_session = core.create_new_session(title):await() + local quick_chat_session = session_runtime.create_new_session(title):await() if not quick_chat_session then spinner:stop() return Promise.new():reject('Failed to create quickchat session') diff --git a/lua/opencode/services/AGENTS.md b/lua/opencode/services/AGENTS.md new file mode 100644 index 00000000..3acedd6e --- /dev/null +++ b/lua/opencode/services/AGENTS.md @@ -0,0 +1,136 @@ +# AGENTS.md (services) + +One-line positioning: +- `services/` = **cross-entry reusable business primitives** +- `handlers/` = **command-entry adapters** + +This directory defines the stable boundary between entry modules and infra-facing modules. + +## Why this layer exists + +`services/` exists to keep dependency growth controlled: + +- entry modules (`ui/**`, `commands/handlers/**`, `quick_chat.lua`) call a small, stable service surface +- infra-facing modules (`session`, `api`, `server_job`, etc.) are not scattered across many entry files +- cross-entry orchestration logic stays here so behavior changes are localized and auditable + +This is a structural boundary, not a temporary migration layer. + +## Relation with handlers + +- `handlers/` should adapt command intents to actions. +- `services/` should hold reusable business actions shared by command/UI/quick_chat entries. +- If logic is needed by non-command entry paths, it belongs in `services/`, not only in `handlers/`. + +## Scope (responsible / not responsible) + +- `session_runtime.lua` + - Responsible for: + - session/runtime orchestration shared by multiple entry modules + - session switching/opening/cancel-related orchestration + - Not responsible for: + - command text parsing + - UI rendering details (layout, buffer paint logic) + - persistence/storage internals in `session.lua` + +- `messaging.lua` + - Responsible for: + - message send flow orchestration + - after-run lifecycle actions + - permission action routing exposed as messaging-facing service API + - Not responsible for: + - direct UI rendering logic + - command routing decisions + - model/mode selection policy + +- `agent_model.lua` + - Responsible for: + - model/mode/provider/variant operations + - model selection-related orchestration API + - Not responsible for: + - session lifecycle orchestration + - permission flow handling + - message send pipeline + +## Required dependency direction + +```text +entry modules (ui/handlers/quick_chat) + -> services/* + -> infra-facing modules (session/api/server_job/...) +``` + +Disallowed shape: + +```text +entry modules -> session/api (new direct scatter) +``` + +## Current boundary debt (file-level TODO) + +The following entry files still directly require `opencode.session`/`opencode.api` and should be removed by routing through services APIs. + +- [ ] `lua/opencode/quick_chat.lua` -> `opencode.session` +- [ ] `lua/opencode/ui/renderer.lua` -> `opencode.session`, `opencode.api` +- [ ] `lua/opencode/ui/debug_helper.lua` -> `opencode.session` +- [ ] `lua/opencode/ui/permission_window.lua` -> `opencode.api` +- [ ] `lua/opencode/ui/contextual_actions.lua` -> `opencode.api` +- [ ] `lua/opencode/ui/session_picker.lua` -> `opencode.api` +- [ ] `lua/opencode/ui/timeline_picker.lua` -> `opencode.api` +- [ ] `lua/opencode/ui/ui.lua` -> `opencode.api` +- [ ] `lua/opencode/commands/handlers/diff.lua` -> `opencode.session` +- [ ] `lua/opencode/commands/handlers/session.lua` -> `opencode.session` + +Sync rule: +- keep this list aligned with `lua/opencode/commands/handlers/AGENTS.md` +- remove an item only after code + tests pass + +## Hard invariants + +- Entry modules must prefer `services/*` over adding new direct `opencode.session` / `opencode.api` requires +- `messaging.lua` and `agent_model.lua` must stay logic-oriented: + - no `vim.api` + - no `vim.fn` + - no `vim.notify` +- Do not add adapter/shim/facade compatibility shells inside `services/` +- Do not silently change behavior contracts of existing service APIs + +## Reject conditions + +Reject a change if any of these happens: + +- New direct entry-layer `require('opencode.session')` or `require('opencode.api')` is introduced without explicit exception approval +- A new compatibility wrapper layer is added only to mask boundary drift +- Unrelated responsibilities are moved into `services/` without boundary update and contract definition + +## Exception policy + +If a direct entry-layer dependency on `session/api` is temporarily unavoidable, the change must include: + +- explicit exception note in the PR description +- reason and scope (file + symbol) +- planned removal condition + +No implicit exceptions. + +## Editing rules + +- Prefer removing scattered direct dependencies over adding glue layers +- Keep service APIs explicit and minimal +- Before introducing a new shared service API, define and freeze its contract (name, inputs, outputs, failure behavior) +- Keep changes local and reversible + +## Minimal regression commands + +- `./run_tests.sh` +- `rg -n "require\(['\"]opencode\.(session|api)['\"]\)" lua/opencode/ui lua/opencode/commands/handlers lua/opencode/quick_chat.lua` +- `! grep -n "vim\.api\|vim\.fn\|vim\.notify" lua/opencode/services/messaging.lua` +- `! grep -n "vim\.api\|vim\.fn\|vim\.notify" lua/opencode/services/agent_model.lua` + +## Notes for new agents + +Treat `services/` as a boundary-control layer: + +- keep entry-to-infra coupling from spreading +- keep service contracts stable +- avoid turning this directory into a generic dumping area diff --git a/lua/opencode/services/agent_model.lua b/lua/opencode/services/agent_model.lua new file mode 100644 index 00000000..0037707f --- /dev/null +++ b/lua/opencode/services/agent_model.lua @@ -0,0 +1,196 @@ +local state = require('opencode.state') +local config_file = require('opencode.config_file') +local util = require('opencode.util') +local Promise = require('opencode.promise') +local log = require('opencode.log') +local ui = require('opencode.ui.ui') + +local M = {} + +function M.configure_provider() + require('opencode.model_picker').select(function(selection) + if not selection then + if state.ui.is_visible() then + ui.focus_input() + end + return + end + local model_str = string.format('%s/%s', selection.provider, selection.model) + state.model.set_model(model_str) + + if state.current_mode then + state.model.set_mode_model_override(state.current_mode, model_str) + end + + if state.ui.is_visible() then + ui.focus_input() + else + log.notify('Changed provider to ' .. model_str, vim.log.levels.INFO) + end + end) +end + +function M.configure_variant() + require('opencode.variant_picker').select(function(selection) + if not selection then + if state.ui.is_visible() then + ui.focus_input() + end + return + end + + state.model.set_variant(selection.name) + + if state.ui.is_visible() then + ui.focus_input() + else + log.notify('Changed variant to ' .. selection.name, vim.log.levels.INFO) + end + end) +end + +M.cycle_variant = Promise.async(function() + if not state.current_model then + log.notify('No model selected', vim.log.levels.WARN) + return + end + + local provider, model = state.current_model:match('^(.-)/(.+)$') + if not provider or not model then + return + end + + local config_file = require('opencode.config_file') + local model_info = config_file.get_model_info(provider, model) + + if not model_info or not model_info.variants then + log.notify('Current model does not support variants', vim.log.levels.WARN) + return + end + + local variants = {} + for variant_name, _ in pairs(model_info.variants) do + table.insert(variants, variant_name) + end + + util.sort_by_priority(variants, function(item) + return item + end, { low = 1, medium = 2, high = 3 }) + + if #variants == 0 then + return + end + + local total_count = #variants + 1 + + local current_index + if state.current_variant == nil then + current_index = total_count + else + current_index = util.index_of(variants, state.current_variant) or 0 + end + + local next_index = (current_index % total_count) + 1 + + local next_variant + if next_index > #variants then + next_variant = nil + else + next_variant = variants[next_index] + end + + state.model.set_variant(next_variant) + + local model_state = require('opencode.model_state') + model_state.set_variant(provider, model, next_variant) +end) + +M.switch_to_mode = Promise.async(function(mode) + if not mode or mode == '' then + log.notify('Mode cannot be empty', vim.log.levels.ERROR) + return false + end + + local available_agents = config_file.get_opencode_agents():await() + + if not vim.tbl_contains(available_agents, mode) then + log.notify( + string.format('Invalid mode "%s". Available modes: %s', mode, table.concat(available_agents, ', ')), + vim.log.levels.ERROR + ) + return false + end + + state.model.set_mode(mode) + local opencode_config = config_file.get_opencode_config():await() --[[@as OpencodeConfigFile]] + + local agent_config = opencode_config and opencode_config.agent or {} + local mode_config = agent_config[mode] or {} + + if state.user_mode_model_map[mode] then + state.model.set_model(state.user_mode_model_map[mode]) + elseif mode_config.model and mode_config.model ~= '' then + state.model.set_model(mode_config.model) + elseif opencode_config and opencode_config.model and opencode_config.model ~= '' then + state.model.set_model(opencode_config.model) + end + return true +end) + +M.ensure_current_mode = Promise.async(function() + if state.current_mode == nil then + local available_agents = config_file.get_opencode_agents():await() + + if not available_agents or #available_agents == 0 then + log.notify('No available agents found', vim.log.levels.ERROR) + return false + end + + local default_mode = require('opencode.config').default_mode + + if default_mode and vim.tbl_contains(available_agents, default_mode) then + return M.switch_to_mode(default_mode):await() + else + return M.switch_to_mode(available_agents[1]):await() + end + end + return true +end) + +---@class InitializeCurrentModelOpts +---@field restore_from_messages? boolean Restore model/mode from the most recent session message + +---@param opts? InitializeCurrentModelOpts +---@return string|nil The current model +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 + 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 + state.model.set_mode(msg.info.mode) + end + return state.current_model + end + end + end + + if state.current_model then + return state.current_model + end + + local cfg = config_file.get_opencode_config():await() + if cfg and cfg.model and cfg.model ~= '' then + state.model.set_model(cfg.model) + end + + return state.current_model +end) + +return M diff --git a/lua/opencode/services/messaging.lua b/lua/opencode/services/messaging.lua new file mode 100644 index 00000000..23c5c9fa --- /dev/null +++ b/lua/opencode/services/messaging.lua @@ -0,0 +1,97 @@ +local state = require('opencode.state') +local context = require('opencode.context') +local util = require('opencode.util') +local config = require('opencode.config') +local Promise = require('opencode.promise') +local log = require('opencode.log') +local agent_model = require('opencode.services.agent_model') +local session_runtime = require('opencode.services.session_runtime') + +local M = {} + +--- Sends a message to the active session. +--- @param prompt string The message prompt to send. +--- @param opts? SendMessageOpts +M.send_message = Promise.async(function(prompt, opts) + if not state.active_session or not state.active_session.id then + return false + end + + local mentioned_files = context.get_context().mentioned_files or {} + local allowed, err_msg = util.check_prompt_allowed(config.prompt_guard, mentioned_files) + + if not allowed then + log.notify(err_msg or 'Prompt denied by prompt_guard', vim.log.levels.ERROR) + return + end + + opts = opts or {} + + opts.context = vim.tbl_deep_extend('force', state.current_context_config or {}, opts.context or {}) + 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 + opts.variant = opts.variant or state.current_variant + local params = {} + + if opts.model then + local provider, model = opts.model:match('^(.-)/(.+)$') + params.model = { providerID = provider, modelID = model } + state.model.set_model(opts.model) + + if opts.variant then + params.variant = opts.variant + state.model.set_variant(opts.variant) + end + end + + if opts.agent then + params.agent = opts.agent + state.model.set_mode(opts.agent) + end + + params.parts = context.format_message(prompt, opts.context):await() + params.system = opts.system or config.default_system_prompt or nil + + local session_id = state.active_session.id + + local function update_sent_message_count(num) + local sent_message_count = vim.deepcopy(state.user_message_count) + local new_value = (sent_message_count[session_id] or 0) + num + sent_message_count[session_id] = new_value >= 0 and new_value or 0 + state.session.set_user_message_count(sent_message_count) + end + + update_sent_message_count(1) + + state.api_client + :create_message(session_id, params) + :and_then(function(response) + update_sent_message_count(-1) + + if not response or not response.info or not response.parts then + log.notify('Invalid response from opencode: ' .. vim.inspect(response), vim.log.levels.ERROR) + session_runtime.cancel():await() + return + end + + M.after_run(prompt) + end) + :catch(function(err) + log.notify('Error sending message to session: ' .. vim.inspect(err), vim.log.levels.ERROR) + update_sent_message_count(-1) + session_runtime.cancel():await() + end) +end) + +---@param prompt string +function M.after_run(prompt) + context.unload_attachments() + state.session.set_last_sent_context(vim.deepcopy(context.get_context())) + context.delta_context() + require('opencode.history').write(prompt) + vim.g.opencode_abort_count = 0 +end + +return M diff --git a/lua/opencode/services/session_runtime.lua b/lua/opencode/services/session_runtime.lua new file mode 100644 index 00000000..dd0755cf --- /dev/null +++ b/lua/opencode/services/session_runtime.lua @@ -0,0 +1,319 @@ +local state = require('opencode.state') +local context = require('opencode.context') +local session = require('opencode.session') +local ui = require('opencode.ui.ui') +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 image_handler = require('opencode.image_handler') +local Promise = require('opencode.promise') +local log = require('opencode.log') +local agent_model = require('opencode.services.agent_model') + +local M = {} + +---@param parent_id string? +M.select_session = Promise.async(function(parent_id) + local all_sessions = session.get_all_workspace_sessions():await() or {} + ---@cast all_sessions Session[] + + local filtered_sessions = vim.tbl_filter(function(s) + return s.title ~= '' and s ~= nil and s.parentID == parent_id + end, all_sessions) + + if #filtered_sessions == 0 then + vim.notify(parent_id and 'No child sessions found' or 'No sessions found', vim.log.levels.INFO) + if state.ui.is_visible() then + ui.focus_input() + end + return + end + + ui.select_session(filtered_sessions, function(selected_session) + if not selected_session then + if state.ui.is_visible() then + ui.focus_input() + end + return + end + M.switch_session(selected_session.id) + end) +end) + +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() + + state.session.set_active(selected_session) + if state.ui.is_visible() then + ui.focus_input() + else + M.open() + end +end) + +---@param opts? OpenOpts +M.open_if_closed = Promise.async(function(opts) + if not state.ui.is_visible() then + M.open(opts):await() + end +end) + +M.is_prompting_allowed = function() + local mentioned_files = context.get_context().mentioned_files or {} + local allowed, err_msg = util.check_prompt_allowed(config.prompt_guard, mentioned_files) + if not allowed then + vim.notify(err_msg or 'Prompt denied by prompt_guard', vim.log.levels.ERROR) + end + return allowed +end + +M.check_cwd = function() + if state.current_cwd ~= vim.fn.getcwd() then + log.debug( + 'CWD changed since last check, resetting session and context', + { current_cwd = state.current_cwd, new_cwd = vim.fn.getcwd() } + ) + state.context.set_current_cwd(vim.fn.getcwd()) + state.session.clear_active() + context.unload_attachments() + end +end + +---@param opts? OpenOpts +M.open = Promise.async(function(opts) + opts = opts or { focus = 'input', new_session = false } + + state.ui.set_opening(true) + + if not require('opencode.ui.ui').is_opencode_focused() then + require('opencode.context').load() + end + + local open_windows_action = opts.open_action or state.ui.resolve_open_windows_action() + local are_windows_closed = open_windows_action ~= 'reuse_visible' + local restoring_hidden = open_windows_action == 'restore_hidden' + + if are_windows_closed then + if not ui.is_opencode_focused() then + state.ui.set_code_context(vim.api.nvim_get_current_win(), vim.api.nvim_get_current_buf()) + end + + M.is_prompting_allowed() + + if restoring_hidden then + local restored = ui.restore_hidden_windows() + if not restored then + state.ui.clear_hidden_window_state() + restoring_hidden = false + state.ui.set_windows(ui.create_windows()) + end + else + state.ui.set_windows(ui.create_windows()) + end + end + + if opts.focus == 'input' then + ui.focus_input({ restore_position = are_windows_closed, start_insert = opts.start_insert == true }) + elseif opts.focus == 'output' then + ui.focus_output({ restore_position = are_windows_closed }) + end + + local server = server_job.ensure_server():await() + + if not server then + state.ui.set_opening(false) + return Promise.new():reject('Server failed to start') + end + + M.check_cwd() + + local ok, err = pcall(function() + if opts.new_session then + state.session.clear_active() + context.unload_attachments() + agent_model.ensure_current_mode():await() + state.session.set_active(M.create_new_session():await()) + log.debug('Created new session on open', { session = state.active_session.id }) + else + agent_model.ensure_current_mode():await() + if not state.active_session then + state.session.set_active(session.get_last_workspace_session():await()) + if not state.active_session then + state.session.set_active(M.create_new_session():await()) + end + elseif not state.display_route and are_windows_closed and not restoring_hidden then + ui.render_output() + end + end + + state.ui.set_panel_focused(true) + end) + + state.ui.set_opening(false) + + if not ok then + vim.notify('Error opening panel: ' .. tostring(err), vim.log.levels.ERROR) + return Promise.new():reject(err) + end + return Promise.new():resolve('ok') +end) + +---@param title_or_opts? string|boolean|table +---@return Session? +M.create_new_session = Promise.async(function(title_or_opts) + local session_request = false + + if type(title_or_opts) == 'string' then + session_request = { title = title_or_opts } + elseif type(title_or_opts) == 'table' and next(title_or_opts) ~= nil then + session_request = title_or_opts + end + + local session_response = state.api_client + :create_session(session_request) + :catch(function(err) + vim.notify('Error creating new session: ' .. vim.inspect(err), vim.log.levels.ERROR) + end) + :await() + + if session_response and session_response.id then + local new_session = session.get_by_id(session_response.id):await() + return new_session + end +end) + +---@param opts? SendMessageOpts +function M.before_run(opts) + local is_new_session = opts and opts.new_session or not state.active_session + M.open({ + new_session = is_new_session, + }) +end + +---@param opts? SendMessageOpts +M.cancel = Promise.async(function() + if state.active_session and state.jobs.is_running() then + vim.g.opencode_abort_count = (vim.g.opencode_abort_count or 0) + 1 + + local permissions = state.pending_permissions or {} + if #permissions > 0 and state.api_client then + for _, permission in ipairs(permissions) do + state.api_client:respond_to_permission(permission.sessionID, permission.id, { response = 'reject' }) + end + end + + local ok, result = pcall(function() + return state.api_client:abort_session(state.active_session.id):wait() + end) + + if not ok then + vim.notify('Abort error: ' .. vim.inspect(result), vim.log.levels.ERROR) + end + + if vim.g.opencode_abort_count >= 3 then + vim.notify('Re-starting Opencode server', vim.log.levels.WARN) + vim.g.opencode_abort_count = 0 + if state.opencode_server then + state.opencode_server:shutdown():await() + end + + state.jobs.clear_server() + state.jobs.set_server(server_job.ensure_server():await() --[[@as OpencodeServer]]) + end + end + + if state.ui.is_visible() then + require('opencode.ui.footer').clear() + input_window.set_content('') + require('opencode.history').index = nil + ui.focus_input() + end +end) + +M.opencode_ok = Promise.async(function() + if vim.fn.executable(config.opencode_executable) == 0 then + vim.notify( + 'opencode command not found - please install and configure opencode before using this plugin', + vim.log.levels.ERROR + ) + return false + end + + if not state.opencode_cli_version or state.opencode_cli_version == '' then + local result = Promise.system({ config.opencode_executable, '--version' }):await() + local out = (result and result.stdout or ''):gsub('%s+$', '') + state.jobs.set_opencode_cli_version(out:match('(%d+%%.%d+%%.%d+)') or out) + end + + local required = state.required_version + local current_version = state.opencode_cli_version + + if not current_version or current_version == '' then + vim.notify(string.format('Unable to detect opencode CLI version. Requires >= %s', required), vim.log.levels.ERROR) + return false + end + + if not util.is_version_greater_or_equal(current_version, required) then + vim.notify( + string.format('Unsupported opencode CLI version: %s. Requires >= %s', current_version, required), + vim.log.levels.ERROR + ) + return false + end + + return true +end) + +M._on_user_message_count_change = Promise.async(function(_, new, old) + require('opencode.ui.renderer.flush').flush_pending_on_data_rendered() + + if config.hooks and config.hooks.on_done_thinking then + local all_sessions = session.get_all_workspace_sessions():await() + local done_sessions = vim.tbl_filter(function(s) + local msg_count = new[s.id] or 0 + local old_msg_count = (old and old[s.id]) or 0 + return msg_count == 0 and old_msg_count > 0 + end, all_sessions or {}) + + for _, done_session in ipairs(done_sessions) do + pcall(config.hooks.on_done_thinking, done_session) + end + end +end) + +M._on_current_permission_change = Promise.async(function(_, new, old) + local permission_requested = #old < #new + if config.hooks and config.hooks.on_permission_requested and permission_requested then + local local_session = (state.active_session and state.active_session.id) + and session.get_by_id(state.active_session.id):await() + or {} + pcall(config.hooks.on_permission_requested, local_session) + end +end) + +M.handle_directory_change = Promise.async(function() + local cwd = vim.fn.getcwd() + log.debug('Working directory change %s', vim.inspect({ cwd = cwd })) + vim.notify('Loading last session for new working dir [' .. cwd .. ']', vim.log.levels.INFO) + + state.session.clear_active() + context.unload_attachments() + + state.session.set_active(session.get_last_workspace_session():await() or M.create_new_session():await()) + + log.debug('Loaded session for new working dir ' .. vim.inspect({ session = state.active_session })) +end) + +function M.paste_image_from_clipboard() + return image_handler.paste_image_from_clipboard() +end + +function M.setup() + return true +end + +return M diff --git a/lua/opencode/ui/autocmds.lua b/lua/opencode/ui/autocmds.lua index a3a345cd..6bceadf5 100644 --- a/lua/opencode/ui/autocmds.lua +++ b/lua/opencode/ui/autocmds.lua @@ -82,8 +82,7 @@ function M.setup_autocmds(windows) end state.context.set_current_cwd(event.file) - local core = require('opencode.core') - core.handle_directory_change() + require('opencode.services.session_runtime').handle_directory_change() end, }) diff --git a/lua/opencode/ui/history_picker.lua b/lua/opencode/ui/history_picker.lua index b3e3e1c5..429d6bef 100644 --- a/lua/opencode/ui/history_picker.lua +++ b/lua/opencode/ui/history_picker.lua @@ -93,7 +93,7 @@ function M.pick(callback) local state = require('opencode.state') local windows = state.windows if not input_window.mounted(windows) then - require('opencode.core').open({ focus_input = true }) + require('opencode.services.session_runtime').open({ focus = 'input' }) windows = state.windows end ---@cast windows { input_win: integer, input_buf: integer } diff --git a/lua/opencode/ui/input_window.lua b/lua/opencode/ui/input_window.lua index b3aeefb6..7c572dfd 100644 --- a/lua/opencode/ui/input_window.lua +++ b/lua/opencode/ui/input_window.lua @@ -146,7 +146,7 @@ function M.handle_submit() return false end - require('opencode.core').send_message(input_content) + require('opencode.services.messaging').send_message(input_content) return true end diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 62bdb8d6..c96bdc19 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -388,7 +388,7 @@ function M._render_full_session_data(session_data, opts) flush.end_bulk_mode() if opts.restore_model_from_messages then - require('opencode.core').initialize_current_model({ restore_from_messages = true }) + require('opencode.services.agent_model').initialize_current_model({ restore_from_messages = true }) end M.scroll_to_bottom(true) diff --git a/lua/opencode/ui/session_picker.lua b/lua/opencode/ui/session_picker.lua index ff990238..766dc262 100644 --- a/lua/opencode/ui/session_picker.lua +++ b/lua/opencode/ui/session_picker.lua @@ -53,6 +53,7 @@ function M.pick(sessions, callback) multi_selection = true, fn = Promise.async(function(selected, opts) local state = require('opencode.state') + local session_runtime = require('opencode.services.session_runtime') local sessions_to_delete = type(selected) == 'table' and selected.id == nil and selected or { selected } @@ -64,16 +65,15 @@ function M.pick(sessions, callback) local deleting_current = state.active_session and to_delete_ids[state.active_session.id] or false if deleting_current then - local core = require('opencode.core') local remaining = vim.tbl_filter(function(item) return not to_delete_ids[item.id] end, opts.items or {}) if #remaining > 0 then - core.switch_session(remaining[1].id):await() + session_runtime.switch_session(remaining[1].id):await() else vim.notify('deleting current session, creating new session') - state.session.set_active(core.create_new_session():await()) + state.session.set_active(session_runtime.create_new_session():await()) end end @@ -101,6 +101,7 @@ function M.pick(sessions, callback) key = config.keymap.session_picker.new_session, label = 'new', fn = Promise.async(function(selected, opts) + local session_runtime = require('opencode.services.session_runtime') local parent_id for _, s in ipairs(opts.items or {}) do if s.parentID ~= nil then @@ -108,14 +109,12 @@ function M.pick(sessions, callback) break end end - local state = require('opencode.state') - local created = state.api_client:create_session(parent_id and { parentID = parent_id } or false):await() - if created and created.id then - local new_session = require('opencode.session').get_by_id(created.id):await() + + local new_session = session_runtime.create_new_session(parent_id and { parentID = parent_id } or false):await() + if new_session then table.insert(opts.items, 1, new_session) return opts.items end - return nil end), reload = true, }, diff --git a/tests/unit/api_spec.lua b/tests/unit/api_spec.lua index 6a664a05..0493e9b2 100644 --- a/tests/unit/api_spec.lua +++ b/tests/unit/api_spec.lua @@ -2,13 +2,146 @@ local api = require('opencode.api') local commands = require('opencode.commands') local command_parse = require('opencode.commands.parse') local slash = require('opencode.commands.slash') -local core = require('opencode.core') +local session_runtime = require('opencode.services.session_runtime') +local messaging = require('opencode.services.messaging') +local agent_model = require('opencode.services.agent_model') +local context = require('opencode.context') +local input_window = require('opencode.ui.input_window') local ui = require('opencode.ui.ui') local state = require('opencode.state') local stub = require('luassert.stub') local assert = require('luassert') local Promise = require('opencode.promise') +---@param id string +---@return Session +local function mk_session(id) + ---@type Session + return { + id = id, + workspace = '/mock/workspace', + title = id, + time = { created = 0, updated = 0 }, + parentID = nil, + } +end + +---@return OpencodeApiClient +local function mk_api_client_for_test() + ---@type OpencodeApiClient + local client = { + base_url = 'http://127.0.0.1:4000', + create_message = function(_, _, _) + local promise = Promise.new() + promise:resolve({ + info = { + id = 'message-1', + sessionID = 'session-1', + tokens = { reasoning = 0, input = 0, output = 0, cache = { write = 0, read = 0 } }, + system = {}, + time = { created = 0, completed = 0 }, + cost = 0, + path = { cwd = '/mock/workspace', root = '/mock/workspace' }, + modelID = 'model', + providerID = 'provider', + role = 'assistant', + system_role = nil, + mode = nil, + error = {}, + }, + parts = { { type = 'text', text = 'ok' } }, + }) + return promise + end, + } + return client +end + +---@generic T +---@param value T +---@return Promise +local function resolved(value) + return Promise.new():resolve(value) +end + +---@param user_commands table|nil +---@param fn fun() +local function with_user_commands(user_commands, fn) + local config_file = require('opencode.config_file') + local original_get_user_commands = config_file.get_user_commands + + config_file.get_user_commands = function() + return resolved(user_commands) + end + + local ok, err = pcall(fn) + config_file.get_user_commands = original_get_user_commands + if not ok then + error(err) + end +end + +---@param config table +---@param fn fun() +local function with_opencode_config(config, fn) + local config_file = require('opencode.config_file') + local original_get_opencode_config = config_file.get_opencode_config + + config_file.get_opencode_config = function() + return resolved(config) + end + + local ok, err = pcall(fn) + config_file.get_opencode_config = original_get_opencode_config + if not ok then + error(err) + end +end + +---@param fn fun() +local function with_model_runtime_snapshot(fn) + local original_model = state.current_model + local original_mode = state.current_mode + local original_messages = state.messages + + local ok, err = pcall(fn) + + state.model.set_model(original_model) + state.model.set_mode(original_mode) + state.renderer.set_messages(original_messages) + + if not ok then + error(err) + end +end + +---@param fn fun() +local function with_session_client_snapshot(fn) + local original_active_session = state.active_session + local original_api_client = state.api_client + + local ok, err = pcall(fn) + + state.session.set_active(original_active_session) + state.jobs.set_api_client(original_api_client) + + if not ok then + error(err) + end +end + +---@param commands_list table[] +---@param slash_name string +---@return table|nil +local function find_slash_command(commands_list, slash_name) + for _, cmd in ipairs(commands_list) do + if cmd.slash_cmd == slash_name then + return cmd + end + end + return nil +end + describe('opencode.api', function() local created_commands = {} @@ -21,13 +154,12 @@ describe('opencode.api', function() opts = opts, }) end) - stub(core, 'open').invokes(function() - return Promise.new():resolve('done') + stub(session_runtime, 'open').invokes(function() + return resolved('done') end) - stub(core, 'run') - stub(core, 'cancel') - stub(core, 'send_message') + stub(session_runtime, 'cancel') + stub(messaging, 'send_message') stub(ui, 'close_windows') end) @@ -141,14 +273,22 @@ describe('opencode.api', function() end) describe('Lua API', function() + local function assert_send_message_called_with(prompt, new_session) + assert.stub(messaging.send_message).was_called() + assert.stub(messaging.send_message).was_called_with(prompt, { + new_session = new_session, + focus = 'output', + }) + end + it('provides callable functions that match commands', function() assert.is_function(api.open_input, 'Should export open_input') api.open_input():wait() - assert.stub(core.open).was_called() - assert.stub(core.open).was_called_with({ new_session = false, focus = 'input', start_insert = true }) + assert.stub(session_runtime.open).was_called() + assert.stub(session_runtime.open).was_called_with({ new_session = false, focus = 'input', start_insert = true }) - local create_new_session_stub = stub(core, 'create_new_session').invokes(function() - return Promise.new():resolve({ id = 'session-1' }) + local create_new_session_stub = stub(session_runtime, 'create_new_session').invokes(function() + return resolved({ id = 'session-1' }) end) local set_active_stub = stub(state.session, 'set_active') @@ -156,25 +296,60 @@ describe('opencode.api', function() api.open_input_new_session_with_title('My Session'):wait() assert.stub(create_new_session_stub).was_called_with('My Session') assert.stub(set_active_stub).was_called_with({ id = 'session-1' }) - assert.stub(core.open).was_called_with({ new_session = false, focus = 'input', start_insert = true }) create_new_session_stub:revert() set_active_stub:revert() assert.is_function(api.run, 'Should export run') api.run('test prompt'):wait() - assert.stub(core.send_message).was_called() - assert.stub(core.send_message).was_called_with('test prompt', { - new_session = false, - focus = 'output', - }) + assert_send_message_called_with('test prompt', false) assert.is_function(api.run_new_session, 'Should export run_new_session') api.run_new_session('test prompt new'):wait() - assert.stub(core.send_message).was_called() - assert.stub(core.send_message).was_called_with('test prompt new', { - new_session = true, - focus = 'output', - }) + assert_send_message_called_with('test prompt new', true) + end) + + it('routes submit_input_prompt through handle_submit, send_message, and after_run', function() + with_session_client_snapshot(function() + with_model_runtime_snapshot(function() + state.session.set_active(mk_session('session-1')) + state.jobs.set_api_client(mk_api_client_for_test()) + + stub(context, 'get_context').returns({ mentioned_files = {} }) + stub(context, 'load') + stub(context, 'format_message').invokes(function() + return resolved({ { type = 'text', text = 'hello' } }) + end) + stub(agent_model, 'initialize_current_model').invokes(function() + return resolved('provider/model') + end) + + local after_run_stub = stub(messaging, 'after_run') + local send_message_stub = stub(messaging, 'send_message').invokes(function(prompt) + require('opencode.services.messaging').after_run(prompt) + return true + end) + local handle_submit_stub = stub(input_window, 'handle_submit').invokes(function() + require('opencode.services.messaging').send_message('hello') + return true + end) + local is_hidden_stub = stub(input_window, 'is_hidden').returns(true) + + api.submit_input_prompt():wait() + + assert.stub(handle_submit_stub).was_called() + assert.stub(send_message_stub).was_called_with('hello') + assert.stub(after_run_stub).was_called_with('hello') + + send_message_stub:revert() + after_run_stub:revert() + handle_submit_stub:revert() + agent_model.initialize_current_model:revert() + context.format_message:revert() + context.load:revert() + context.get_context:revert() + is_hidden_stub:revert() + end) + end) end) end) @@ -193,121 +368,75 @@ describe('opencode.api', function() describe('/commands command', function() it('displays user commands when available', function() - local config_file = require('opencode.config_file') - local original_get_user_commands = config_file.get_user_commands - - config_file.get_user_commands = function() - local p = Promise.new() - p:resolve({ - ['build'] = { description = 'Build the project' }, - ['test'] = { description = 'Run tests' }, - ['deploy'] = { description = 'Deploy to production' }, - }) - return p - end - - stub(ui, 'render_lines') - - api.commands_list():wait() - - assert.stub(core.open).was_called_with({ new_session = false, focus = 'input', start_insert = true }) - assert.stub(ui.render_lines).was_called() - - local render_args = ui.render_lines.calls[1].refs[1] - local rendered_text = table.concat(render_args, '\n') - - assert.truthy(rendered_text:match('Available User Commands')) - assert.truthy(rendered_text:match('Description')) - assert.truthy(rendered_text:match('build')) - assert.truthy(rendered_text:match('Build the project')) - assert.truthy(rendered_text:match('test')) - assert.truthy(rendered_text:match('Run tests')) - assert.truthy(rendered_text:match('deploy')) - assert.truthy(rendered_text:match('Deploy to production')) - - config_file.get_user_commands = original_get_user_commands + with_user_commands({ + ['build'] = { description = 'Build the project' }, + ['test'] = { description = 'Run tests' }, + ['deploy'] = { description = 'Deploy to production' }, + }, function() + stub(ui, 'render_lines') + + api.commands_list():wait() + + assert.stub(session_runtime.open).was_called_with({ new_session = false, focus = 'input', start_insert = true }) + assert.stub(ui.render_lines).was_called() + + ---@diagnostic disable-next-line: undefined-field + local render_args = ui.render_lines.calls[1].refs[1] + local rendered_text = table.concat(render_args, '\n') + + assert.truthy(rendered_text:match('Available User Commands')) + assert.truthy(rendered_text:match('Description')) + assert.truthy(rendered_text:match('build')) + assert.truthy(rendered_text:match('Build the project')) + assert.truthy(rendered_text:match('test')) + assert.truthy(rendered_text:match('Run tests')) + assert.truthy(rendered_text:match('deploy')) + assert.truthy(rendered_text:match('Deploy to production')) + end) end) it('shows warning when no user commands exist', function() - local config_file = require('opencode.config_file') - local original_get_user_commands = config_file.get_user_commands + with_user_commands(nil, function() + local notify_stub = stub(vim, 'notify') - config_file.get_user_commands = function() - local p = Promise.new() - p:resolve(nil) - return p - end + api.commands_list():wait() - local notify_stub = stub(vim, 'notify') + assert + .stub(notify_stub) + .was_called_with('No user commands found. Please check your opencode config file.', vim.log.levels.WARN) - api.commands_list():wait() - - assert - .stub(notify_stub) - .was_called_with('No user commands found. Please check your opencode config file.', vim.log.levels.WARN) - - config_file.get_user_commands = original_get_user_commands - notify_stub:revert() + notify_stub:revert() + end) end) end) describe('command autocomplete', function() it('filters user command completions by arg lead', function() - local config_file = require('opencode.config_file') - local original_get_user_commands = config_file.get_user_commands - - config_file.get_user_commands = function() - local p = Promise.new() - p:resolve({ - ['build'] = { description = 'Build the project' }, - ['deploy'] = { description = 'Deploy to production' }, - }) - return p - end - - local completions = commands.complete_command('b', 'Opencode command b', 18) - - assert.same({ 'build' }, completions) - - config_file.get_user_commands = original_get_user_commands + with_user_commands({ + ['build'] = { description = 'Build the project' }, + ['deploy'] = { description = 'Deploy to production' }, + }, function() + local completions = commands.complete_command('b', 'Opencode command b', 18) + assert.same({ 'build' }, completions) + end) end) it('provides sorted user command names for completion', function() - local config_file = require('opencode.config_file') - local original_get_user_commands = config_file.get_user_commands - - config_file.get_user_commands = function() - local p = Promise.new() - p:resolve({ - ['build'] = { description = 'Build the project' }, - ['test'] = { description = 'Run tests' }, - ['deploy'] = { description = 'Deploy to production' }, - }) - return p - end - - local completions = commands.complete_command('', 'Opencode command ', 17) - - assert.same({ 'build', 'deploy', 'test' }, completions) - - config_file.get_user_commands = original_get_user_commands + with_user_commands({ + ['build'] = { description = 'Build the project' }, + ['test'] = { description = 'Run tests' }, + ['deploy'] = { description = 'Deploy to production' }, + }, function() + local completions = commands.complete_command('', 'Opencode command ', 17) + assert.same({ 'build', 'deploy', 'test' }, completions) + end) end) it('returns empty array when no user commands exist', function() - local config_file = require('opencode.config_file') - local original_get_user_commands = config_file.get_user_commands - - config_file.get_user_commands = function() - local p = Promise.new() - p:resolve(nil) - return p - end - - local completions = commands.complete_command('', 'Opencode command ', 17) - - assert.same({}, completions) - - config_file.get_user_commands = original_get_user_commands + with_user_commands(nil, function() + local completions = commands.complete_command('', 'Opencode command ', 17) + assert.same({}, completions) + end) end) it('returns empty array for invalid provider id', function() @@ -332,187 +461,120 @@ describe('opencode.api', function() describe('user command model/agent selection', function() before_each(function() stub(api, 'open_input').invokes(function() - return Promise.new():resolve('done') + return resolved('done') end) end) it('invokes run with correct model and agent', function() - local config_file = require('opencode.config_file') - local original_get_user_commands = config_file.get_user_commands - - config_file.get_user_commands = function() - local p = Promise.new() - p:resolve({ - ['test-with-model'] = { - description = 'Run tests', - template = 'Run tests with $ARGUMENTS', - model = 'openai/gpt-4', - agent = 'tester', - }, - }) - return p - end - - local original_active_session = state.active_session - state.session.set_active({ id = 'test-session' }) - - local original_api_client = state.api_client - local send_command_calls = {} - state.jobs.set_api_client({ - send_command = function(self, session_id, command_data) - table.insert(send_command_calls, { session_id = session_id, command_data = command_data }) - return { - and_then = function() - return {} + with_user_commands({ + ['test-with-model'] = { + description = 'Run tests', + template = 'Run tests with $ARGUMENTS', + model = 'openai/gpt-4', + agent = 'tester', + }, + }, function() + with_session_client_snapshot(function() + state.session.set_active(mk_session('test-session')) + + local send_command_calls = {} + state.jobs.set_api_client({ + base_url = 'http://127.0.0.1:4000', + send_command = function(_self, session_id, command_data) + table.insert(send_command_calls, { session_id = session_id, command_data = command_data }) + return { + and_then = function() + return {} + end, + } end, - } - end, - }) + }) - local slash_commands = slash.get_commands():wait() + local slash_commands = slash.get_commands():wait() + local test_with_model_cmd = find_slash_command(slash_commands, '/test-with-model') - local test_with_model_cmd + assert.truthy(test_with_model_cmd, 'Should find /test-with-model command') - for _, cmd in ipairs(slash_commands) do - if cmd.slash_cmd == '/test-with-model' then - test_with_model_cmd = cmd - end - end - - assert.truthy(test_with_model_cmd, 'Should find /test-with-model command') - - test_with_model_cmd.fn():wait() - assert.equal(1, #send_command_calls) - assert.equal('test-session', send_command_calls[1].session_id) - assert.equal('test-with-model', send_command_calls[1].command_data.command) - assert.equal('', send_command_calls[1].command_data.arguments) - assert.equal('openai/gpt-4', send_command_calls[1].command_data.model) - assert.equal('tester', send_command_calls[1].command_data.agent) - - config_file.get_user_commands = original_get_user_commands - state.session.set_active(original_active_session) - state.jobs.set_api_client(original_api_client) + test_with_model_cmd.fn():wait() + assert.equal(1, #send_command_calls) + assert.equal('test-session', send_command_calls[1].session_id) + assert.equal('test-with-model', send_command_calls[1].command_data.command) + assert.equal('', send_command_calls[1].command_data.arguments) + assert.equal('openai/gpt-4', send_command_calls[1].command_data.model) + assert.equal('tester', send_command_calls[1].command_data.agent) + end) + end) end) end) it('uses default description when none provided', function() - local config_file = require('opencode.config_file') - local original_get_user_commands = config_file.get_user_commands - - config_file.get_user_commands = function() - local p = Promise.new() - p:resolve({ - ['custom'] = {}, - }) - return p - end - - local slash_commands = slash.get_commands():wait() - - local custom_found = false - for _, cmd in ipairs(slash_commands) do - if cmd.slash_cmd == '/custom' then - custom_found = true - assert.equal('User command', cmd.desc) - end - end - - assert.truthy(custom_found, 'Should include /custom command') + with_user_commands({ ['custom'] = {} }, function() + local slash_commands = slash.get_commands():wait() + local custom_cmd = find_slash_command(slash_commands, '/custom') - config_file.get_user_commands = original_get_user_commands + assert.truthy(custom_cmd, 'Should include /custom command') + assert.equal('User command', custom_cmd.desc) + end) end) it('includes built-in slash commands alongside user commands', function() - local config_file = require('opencode.config_file') - local original_get_user_commands = config_file.get_user_commands - - config_file.get_user_commands = function() - local p = Promise.new() - p:resolve({ - ['build'] = { description = 'Build the project' }, - }) - return p - end - - local slash_commands = slash.get_commands():wait() - - local help_found = false - local build_found = false - - for _, cmd in ipairs(slash_commands) do - if cmd.slash_cmd == '/help' then - help_found = true - elseif cmd.slash_cmd == '/build' then - build_found = true - end - end - - assert.truthy(help_found, 'Should include built-in /help command') - assert.truthy(build_found, 'Should include user /build command') + with_user_commands({ + ['build'] = { description = 'Build the project' }, + }, function() + local slash_commands = slash.get_commands():wait() + local help_cmd = find_slash_command(slash_commands, '/help') + local build_cmd = find_slash_command(slash_commands, '/build') - config_file.get_user_commands = original_get_user_commands + assert.truthy(help_cmd, 'Should include built-in /help command') + assert.truthy(build_cmd, 'Should include user /build command') + end) end) end) describe('current_model', function() it('returns the current model from state', function() - local original_model = state.current_model - state.model.set_model('testmodel') - - local model = api.current_model():wait() - assert.equal('testmodel', model) + with_model_runtime_snapshot(function() + state.model.set_model('testmodel') - state.model.set_model(original_model) + local model = api.current_model():wait() + assert.equal('testmodel', model) + end) end) it('falls back to config file model when state.current_model is nil', function() - local original_model = state.current_model - state.model.clear_model() - - local config_file = require('opencode.config_file') - local original_get_opencode_config = config_file.get_opencode_config - - config_file.get_opencode_config = function() - local p = Promise.new() - p:resolve({ model = 'testmodel' }) - return p - end - - local model = api.current_model():wait() - - assert.equal('testmodel', model) - - config_file.get_opencode_config = original_get_opencode_config - state.model.set_model(original_model) + with_model_runtime_snapshot(function() + state.model.clear_model() + state.model.clear_mode() + state.renderer.set_messages(nil) + + with_opencode_config({ model = 'testmodel' }, function() + local model = api.current_model():wait() + assert.equal('testmodel', model) + end) + end) end) it('does not overwrite a user-selected model from prior session messages', function() - local original_model = state.current_model - local original_mode = state.current_mode - local original_messages = state.messages - - state.model.set_model('openai/gpt-4.1') - state.model.set_mode('plan') - state.renderer.set_messages({ - { - info = { - id = 'm1', - providerID = 'anthropic', - modelID = 'claude-3-opus', - mode = 'build', + with_model_runtime_snapshot(function() + state.model.set_model('openai/gpt-4.1') + state.model.set_mode('plan') + state.renderer.set_messages({ + { + info = { + id = 'm1', + providerID = 'anthropic', + modelID = 'claude-3-opus', + mode = 'build', + }, }, - }, - }) - - local model = api.current_model():wait() + }) - assert.equal('openai/gpt-4.1', model) - assert.equal('openai/gpt-4.1', state.current_model) - assert.equal('plan', state.current_mode) + local model = api.current_model():wait() - state.renderer.set_messages(original_messages) - state.model.set_mode(original_mode) - state.model.set_model(original_model) + assert.equal('openai/gpt-4.1', model) + assert.equal('openai/gpt-4.1', state.current_model) + assert.equal('plan', state.current_mode) + end) end) end) diff --git a/tests/unit/commands_handlers_spec.lua b/tests/unit/commands_handlers_spec.lua index 484a69ee..e57a1d95 100644 --- a/tests/unit/commands_handlers_spec.lua +++ b/tests/unit/commands_handlers_spec.lua @@ -3,9 +3,11 @@ local stub = require('luassert.stub') describe('opencode.commands.handlers', function() local tracked_modules = { - 'opencode.core', 'opencode.state', 'opencode.promise', + 'opencode.services.session_runtime', + 'opencode.services.messaging', + 'opencode.services.agent_model', 'opencode.commands', 'opencode.commands.handlers.window', 'opencode.commands.handlers.agent', diff --git a/tests/unit/hooks_spec.lua b/tests/unit/hooks_spec.lua index eeaecffb..2df42ffe 100644 --- a/tests/unit/hooks_spec.lua +++ b/tests/unit/hooks_spec.lua @@ -1,11 +1,23 @@ local renderer = require('opencode.ui.renderer') local config = require('opencode.config') local state = require('opencode.state') -local core = require('opencode.core') +local session_runtime = require('opencode.services.session_runtime') local events = require('opencode.ui.renderer.events') local helpers = require('tests.helpers') local ui = require('opencode.ui.ui') + +local function expect_nil_hook_no_error(run) + assert.has_no.errors(run) +end + +local function expect_throwing_hook_no_crash(set_hook, run) + set_hook(function() + error('test error') + end) + assert.has_no.errors(run) +end + describe('hooks', function() before_each(function() helpers.replay_setup() @@ -48,20 +60,17 @@ describe('hooks', function() it('should not error when hook is nil', function() config.hooks.on_file_edited = nil - local test_event = { file = '/test/file.lua' } - assert.has_no.errors(function() + expect_nil_hook_no_error(function() events.on_file_edited(test_event) end) end) it('should not crash when hook throws error', function() - config.hooks.on_file_edited = function() - error('test error') - end - local test_event = { file = '/test/file.lua' } - assert.has_no.errors(function() + expect_throwing_hook_no_crash(function(fn) + config.hooks.on_file_edited = fn + end, function() events.on_file_edited(test_event) end) end) @@ -89,26 +98,21 @@ describe('hooks', function() it('should not error when hook is nil', function() config.hooks.on_session_loaded = nil - local events = helpers.load_test_data('tests/data/simple-session.json') state.session.set_active(helpers.get_session_from_events(events, true)) local loaded_session = helpers.load_session_from_events(events) - - assert.has_no.errors(function() + expect_nil_hook_no_error(function() renderer._render_full_session_data(loaded_session) end) end) it('should not crash when hook throws error', function() - config.hooks.on_session_loaded = function() - error('test error') - end - local events = helpers.load_test_data('tests/data/simple-session.json') state.session.set_active(helpers.get_session_from_events(events, true)) local loaded_session = helpers.load_session_from_events(events) - - assert.has_no.errors(function() + expect_throwing_hook_no_crash(function(fn) + config.hooks.on_session_loaded = fn + end, function() renderer._render_full_session_data(loaded_session) end) end) @@ -133,7 +137,7 @@ describe('hooks', function() return promise end - state.store.subscribe('user_message_count', core._on_user_message_count_change) + state.store.subscribe('user_message_count', session_runtime._on_user_message_count_change) -- Simulate job count change from 1 to 0 (done thinking) for a specific session state.session.set_active({ id = 'test-session', title = 'Test' }) @@ -147,7 +151,7 @@ describe('hooks', function() -- Restore original function session_module.get_all_workspace_sessions = original_get_all - state.store.unsubscribe('user_message_count', core._on_user_message_count_change) + state.store.unsubscribe('user_message_count', session_runtime._on_user_message_count_change) assert.is_true(called) assert.are.equal(called_session.id, 'test-session') @@ -157,19 +161,17 @@ describe('hooks', function() config.hooks.on_done_thinking = nil state.session.set_active({ id = 'test-session', title = 'Test' }) state.session.set_user_message_count({ ['test-session'] = 1 }) - assert.has_no.errors(function() + expect_nil_hook_no_error(function() state.session.set_user_message_count({ ['test-session'] = 0 }) end) end) it('should not crash when hook throws error', function() - config.hooks.on_done_thinking = function() - error('test error') - end - state.session.set_active({ id = 'test-session', title = 'Test' }) state.session.set_user_message_count({ ['test-session'] = 1 }) - assert.has_no.errors(function() + expect_throwing_hook_no_crash(function(fn) + config.hooks.on_done_thinking = fn + end, function() state.session.set_user_message_count({ ['test-session'] = 0 }) end) end) @@ -195,7 +197,7 @@ describe('hooks', function() end -- Set up the subscription manually - state.store.subscribe('pending_permissions', core._on_current_permission_change) + state.store.subscribe('pending_permissions', session_runtime._on_current_permission_change) -- Simulate permission change from nil to a value state.session.set_active({ id = 'test-session', title = 'Test' }) @@ -208,7 +210,7 @@ describe('hooks', function() -- Restore original function session_module.get_by_id = original_get_by_id - state.store.unsubscribe('pending_permissions', core._on_current_permission_change) + state.store.unsubscribe('pending_permissions', session_runtime._on_current_permission_change) assert.is_true(called) assert.are.equal(called_session.id, 'test-session') @@ -216,17 +218,15 @@ describe('hooks', function() it('should not error when hook is nil', function() config.hooks.on_permission_requested = nil - assert.has_no.errors(function() + expect_nil_hook_no_error(function() state.renderer.set_pending_permissions({ { tool = 'test_tool', action = 'read' } }) end) end) it('should not crash when hook throws error', function() - config.hooks.on_permission_requested = function() - error('test error') - end - - assert.has_no.errors(function() + expect_throwing_hook_no_crash(function(fn) + config.hooks.on_permission_requested = fn + end, function() state.renderer.set_pending_permissions({ { tool = 'test_tool', action = 'read' } }) end) end) diff --git a/tests/unit/services_agent_model_spec.lua b/tests/unit/services_agent_model_spec.lua new file mode 100644 index 00000000..0bea00d7 --- /dev/null +++ b/tests/unit/services_agent_model_spec.lua @@ -0,0 +1,164 @@ +local loaded = rawget(_G, '__opencode_service_spec_loaded') or {} +_G.__opencode_service_spec_loaded = loaded +if loaded.services_agent_model_spec then + return +end +loaded.services_agent_model_spec = true + +local agent_model = require('opencode.services.agent_model') +local config_file = require('opencode.config_file') +local state = require('opencode.state') +local Promise = require('opencode.promise') +local stub = require('luassert.stub') +local assert = require('luassert') + +describe('opencode.services.agent_model', function() + it('sets current model from config file when mode has a model configured', function() + local agents_promise = Promise.new() + agents_promise:resolve({ 'plan', 'build', 'custom' }) + local config_promise = Promise.new() + config_promise:resolve({ + agent = { + custom = { + model = 'anthropic/claude-3-opus', + }, + }, + model = 'gpt-4', + }) + + stub(config_file, 'get_opencode_agents').returns(agents_promise) + stub(config_file, 'get_opencode_config').returns(config_promise) + + state.store.set('current_mode', nil) + state.store.set('current_model', nil) + state.store.set('user_mode_model_map', {}) + + local promise = agent_model.switch_to_mode('custom') + local success = promise:wait() + + assert.is_true(success) + assert.equal('custom', state.current_mode) + assert.equal('anthropic/claude-3-opus', state.current_model) + + config_file.get_opencode_agents:revert() + config_file.get_opencode_config:revert() + end) + + it('returns false when mode is invalid', function() + local agents_promise = Promise.new() + agents_promise:resolve({ 'plan', 'build' }) + + stub(config_file, 'get_opencode_agents').returns(agents_promise) + + local promise = agent_model.switch_to_mode('nonexistent') + local success = promise:wait() + + assert.is_false(success) + + config_file.get_opencode_agents:revert() + end) + + it('returns false when mode is empty', function() + local promise = agent_model.switch_to_mode('') + local success = promise:wait() + assert.is_false(success) + + promise = agent_model.switch_to_mode(nil) + success = promise:wait() + assert.is_false(success) + end) + + it('respects user_mode_model_map priority: uses model stored in mode_model_map for mode', function() + local agents_promise = Promise.new() + agents_promise:resolve({ 'plan', 'build' }) + local config_promise = Promise.new() + config_promise:resolve({ + agent = { + plan = { model = 'gpt-4' }, + }, + model = 'gpt-3', + }) + stub(config_file, 'get_opencode_agents').returns(agents_promise) + stub(config_file, 'get_opencode_config').returns(config_promise) + + state.store.set('current_mode', nil) + state.store.set('current_model', 'should-be-overridden') + state.store.set('user_mode_model_map', { plan = 'anthropic/claude-3-haiku' }) + + local promise = agent_model.switch_to_mode('plan') + local success = promise:wait() + assert.is_true(success) + assert.equal('plan', state.current_mode) + assert.equal('anthropic/claude-3-haiku', state.current_model) + + config_file.get_opencode_agents:revert() + config_file.get_opencode_config:revert() + end) + + it('falls back to config model if nothing else matches', function() + local agents_promise = Promise.new() + agents_promise:resolve({ 'plan', 'build' }) + local config_promise = Promise.new() + config_promise:resolve({ + agent = { + plan = {}, + }, + model = 'default-model', + }) + stub(config_file, 'get_opencode_agents').returns(agents_promise) + stub(config_file, 'get_opencode_config').returns(config_promise) + state.store.set('current_mode', nil) + state.store.set('current_model', 'old-model') + state.store.set('user_mode_model_map', {}) + + local promise = agent_model.switch_to_mode('plan') + local success = promise:wait() + assert.is_true(success) + assert.equal('plan', state.current_mode) + assert.equal('default-model', state.current_model) + config_file.get_opencode_agents:revert() + config_file.get_opencode_config:revert() + end) + + it('keeps the current user-selected model and mode by default', function() + state.model.set_model('openai/gpt-4.1') + state.model.set_mode('plan') + state.renderer.set_messages({ + { + info = { + id = 'm1', + providerID = 'anthropic', + modelID = 'claude-3-opus', + mode = 'build', + }, + }, + }) + + local model = agent_model.initialize_current_model():wait() + + assert.equal('openai/gpt-4.1', model) + assert.equal('openai/gpt-4.1', state.current_model) + assert.equal('plan', state.current_mode) + end) + + it('restores the latest session model and mode when explicitly requested', function() + state.model.set_model('openai/gpt-4.1') + state.model.set_mode('plan') + state.renderer.set_messages({ + { + info = { + id = 'm1', + providerID = 'anthropic', + modelID = 'claude-3-opus', + mode = 'build', + }, + }, + }) + + local model = agent_model.initialize_current_model({ restore_from_messages = true }):wait() + + assert.equal('anthropic/claude-3-opus', model) + assert.equal('anthropic/claude-3-opus', state.current_model) + assert.equal('build', state.current_mode) + end) +end) diff --git a/tests/unit/services_messaging_spec.lua b/tests/unit/services_messaging_spec.lua new file mode 100644 index 00000000..cd8975f0 --- /dev/null +++ b/tests/unit/services_messaging_spec.lua @@ -0,0 +1,139 @@ +local loaded = rawget(_G, '__opencode_service_spec_loaded') or {} +_G.__opencode_service_spec_loaded = loaded +if loaded.services_messaging_spec then + return +end +loaded.services_messaging_spec = true + +local messaging = require('opencode.services.messaging') +local session_runtime = require('opencode.services.session_runtime') +local state = require('opencode.state') +local Promise = require('opencode.promise') +local stub = require('luassert.stub') +local assert = require('luassert') +local support = require('tests.unit.services_spec_support') + +describe('opencode.services.messaging', function() + before_each(function() + support.mock_api_client() + end) + + it('sends a message via api_client', function() + state.ui.set_windows({ mock = 'windows' }) + state.session.set_active({ id = 'sess1' }) + + local create_called = false + local orig = state.api_client.create_message + state.api_client.create_message = function(_, sid, params) + create_called = true + assert.equal('sess1', sid) + assert.truthy(params.parts) + return Promise.new():resolve({ id = 'm1' }) + end + + messaging.send_message('hello world') + vim.wait(50, function() + return create_called + end) + assert.True(create_called) + state.api_client.create_message = orig + end) + + it('returns false when active session is missing', function() + state.ui.set_windows({ mock = 'windows' }) + state.session.set_active(nil) + + local sent = messaging.send_message('hello world'):wait() + assert.is_false(sent) + end) + + it('persist options in state when sending message', function() + local orig = state.api_client.create_message + state.ui.set_windows({ mock = 'windows' }) + state.session.set_active({ id = 'sess1' }) + + local create_called = false + state.api_client.create_message = function(_, sid, params) + create_called = true + assert.equal('sess1', sid) + assert.truthy(params.parts) + return Promise.new():resolve({ id = 'm1' }) + end + + messaging.send_message( + 'hello world', + { context = { current_file = { enabled = false } }, agent = 'plan', model = 'test/model' } + ) + assert.same(state.current_context_config, { current_file = { enabled = false } }) + assert.equal(state.current_mode, 'plan') + assert.equal(state.current_model, 'test/model') + assert.is_true(create_called) + state.api_client.create_message = orig + end) + + it('increments and decrements user_message_count correctly', function() + state.ui.set_windows({ mock = 'windows' }) + state.session.set_active({ id = 'sess1' }) + state.session.set_user_message_count({}) + + local count_before = state.user_message_count['sess1'] or 0 + local count_during = nil + local count_after = nil + + local orig = state.api_client.create_message + state.api_client.create_message = function(_, sid, params) + count_during = state.user_message_count['sess1'] + return Promise.new():resolve({ + id = 'm1', + info = { id = 'm1' }, + parts = {}, + }) + end + + messaging.send_message('hello world') + + vim.wait(50, function() + count_after = state.user_message_count['sess1'] or 0 + return count_after == 0 + end) + + assert.equal(0, count_before) + assert.equal(1, count_during) + assert.equal(0, count_after) + + state.api_client.create_message = orig + end) + + it('decrements user_message_count on error', function() + state.ui.set_windows({ mock = 'windows' }) + state.session.set_active({ id = 'sess1' }) + state.session.set_user_message_count({}) + + local count_before = state.user_message_count['sess1'] or 0 + local count_during = nil + local count_after = nil + + local orig = state.api_client.create_message + state.api_client.create_message = function(_, sid, params) + count_during = state.user_message_count['sess1'] + return Promise.new():reject('Test error') + end + + local orig_cancel = session_runtime.cancel + stub(session_runtime, 'cancel') + + messaging.send_message('hello world') + + vim.wait(50, function() + count_after = state.user_message_count['sess1'] or 0 + return count_after == 0 + end) + + assert.equal(0, count_before) + assert.equal(1, count_during) + assert.equal(0, count_after) + + state.api_client.create_message = orig + session_runtime.cancel = orig_cancel + end) +end) diff --git a/tests/unit/core_spec.lua b/tests/unit/services_session_runtime_spec.lua similarity index 54% rename from tests/unit/core_spec.lua rename to tests/unit/services_session_runtime_spec.lua index 75072b5f..fadabfe0 100644 --- a/tests/unit/core_spec.lua +++ b/tests/unit/services_session_runtime_spec.lua @@ -1,4 +1,13 @@ -local core = require('opencode.core') +local loaded = rawget(_G, '__opencode_service_spec_loaded') or {} +_G.__opencode_service_spec_loaded = loaded +if loaded.services_session_runtime_spec then + return +end +loaded.services_session_runtime_spec = true + +local session_runtime = require('opencode.services.session_runtime') +local messaging = require('opencode.services.messaging') +local agent_model = require('opencode.services.agent_model') local config_file = require('opencode.config_file') local state = require('opencode.state') local store = require('opencode.state.store') @@ -8,39 +17,13 @@ local Promise = require('opencode.promise') local stub = require('luassert.stub') local assert = require('luassert') local flush = require('opencode.ui.renderer.flush') +local support = require('tests.unit.services_spec_support') --- Provide a mock api_client for tests that need it -local function mock_api_client() - state.jobs.set_api_client({ - create_session = function(_, params) - return Promise.new():resolve({ id = params and params.title or 'new-session' }) - end, - create_message = function(_, sess_id, _params) - return Promise.new():resolve({ id = 'm1', sessionID = sess_id }) - end, - abort_session = function(_, _id) - return Promise.new():resolve(true) - end, - get_current_project = function() - return Promise.new():resolve({ id = 'test-project-id' }) - end, - get_config = function() - return Promise.new():resolve({ model = 'gpt-4' }) - end, - }) -end - -describe('opencode.core', function() - local original_state - local original_system - local original_executable - local original_schedule +describe('opencode.services.session_runtime', function() + local original before_each(function() - original_state = vim.deepcopy(state) - original_system = vim.system - original_executable = vim.fn.executable - original_schedule = vim.schedule + original = support.snapshot_state() vim.fn.executable = function(_) return 1 @@ -74,7 +57,6 @@ describe('opencode.core', function() return p end) if session.get_by_id and type(session.get_by_id) == 'function' then - -- stub get_by_id to return a simple session object without filesystem access stub(session, 'get_by_id').invokes(function(id) local p = Promise.new() if not id then @@ -84,7 +66,6 @@ describe('opencode.core', function() end return p end) - -- stub get_by_name to return a simple session object without filesystem access stub(session, 'get_by_name').invokes(function(name) local p = Promise.new() if not name then @@ -95,9 +76,8 @@ describe('opencode.core', function() return p end) end - mock_api_client() + support.mock_api_client() - -- Mock server job to avoid trying to start real server store.set('opencode_server', { is_running = function() return true @@ -105,17 +85,10 @@ describe('opencode.core', function() shutdown = function() end, url = 'http://127.0.0.1:4000', }) - - -- Config is now loaded lazily, so no need to pre-seed promises end) after_each(function() - for k, v in pairs(original_state) do - store.set(k, v) - end - vim.system = original_system - vim.fn.executable = original_executable - vim.schedule = original_schedule + support.restore_state(original) for _, fn in ipairs({ 'create_windows', @@ -143,7 +116,7 @@ describe('opencode.core', function() describe('open', function() it("creates windows if they don't exist", function() state.ui.set_windows(nil) - core.open({ new_session = false, focus = 'input' }):wait() + session_runtime.open({ new_session = false, focus = 'input' }):wait() assert.truthy(state.windows) assert.same({ mock = 'windows', @@ -157,7 +130,7 @@ describe('opencode.core', function() it('ensure the current cwd is correct when opening', function() local cwd = vim.fn.getcwd() state.context.set_current_cwd(nil) - core.open({ new_session = false, focus = 'input' }):wait() + session_runtime.open({ new_session = false, focus = 'input' }):wait() assert.equal(cwd, state.current_cwd) end) @@ -177,18 +150,17 @@ describe('opencode.core', function() return p end) - core.open({ new_session = false, focus = 'input' }):wait() + session_runtime.open({ new_session = false, focus = 'input' }):wait() assert.truthy(state.active_session) assert.equal('new_cwd-test-session', state.active_session.id) - -- Restore original cwd function vim.fn.getcwd = original_getcwd end) it('handles new session properly', function() state.ui.set_windows(nil) state.session.set_active({ id = 'old-session' }) - core.open({ new_session = true, focus = 'input' }):wait() + session_runtime.open({ new_session = true, focus = 'input' }):wait() assert.truthy(state.active_session) end) @@ -204,12 +176,12 @@ describe('opencode.core', function() output_focused = true end) - core.open({ new_session = false, focus = 'input' }):wait() + session_runtime.open({ new_session = false, focus = 'input' }):wait() assert.is_true(input_focused) assert.is_false(output_focused) input_focused, output_focused = false, false - core.open({ new_session = false, focus = 'output' }):wait() + session_runtime.open({ new_session = false, focus = 'output' }):wait() assert.is_false(input_focused) assert.is_true(output_focused) end) @@ -224,7 +196,7 @@ describe('opencode.core', function() return p end) - core.open({ new_session = false, focus = 'input' }):wait() + session_runtime.open({ new_session = false, focus = 'input' }):wait() assert.truthy(state.active_session) assert.truthy(state.active_session.id) @@ -234,36 +206,81 @@ describe('opencode.core', function() state.ui.set_windows(nil) store.set('is_opening', false) - -- Simply cause an error by stubbing a function that will be called - local original_create_new_session = core.create_new_session - core.create_new_session = function() + local original_create_new_session = session_runtime.create_new_session + session_runtime.create_new_session = function() error('Test error in create_new_session') end local notify_stub = stub(vim, 'notify') - local result_promise = core.open({ new_session = true, focus = 'input' }) + local result_promise = session_runtime.open({ new_session = true, focus = 'input' }) - -- Wait for async operations to complete local ok, err = pcall(function() result_promise:wait() end) - -- Should fail due to the error assert.is_false(ok) assert.truthy(err) - - -- is_opening should be reset to false even when error occurs assert.is_false(state.is_opening) - - -- Should have notified about the error assert.stub(notify_stub).was_called() - -- Restore original function - core.create_new_session = original_create_new_session + session_runtime.create_new_session = original_create_new_session notify_stub:revert() end) end) + describe('setup', function() + it('registers key subscriptions only once across repeated setup calls', function() + local original_opencode = package.loaded['opencode'] + package.loaded['opencode'] = nil + + local opencode = require('opencode') + local config = require('opencode.config') + local highlight = require('opencode.ui.highlight') + local commands = require('opencode.commands') + local completion = require('opencode.ui.completion') + local keymap = require('opencode.keymap') + local event_manager = require('opencode.event_manager') + local context = require('opencode.context') + local context_bar = require('opencode.ui.context_bar') + local reference_picker = require('opencode.ui.reference_picker') + local subscriptions = {} + + local original_subscribe = state.store.subscribe + state.store.subscribe = function(key, cb) + table.insert(subscriptions, key) + return cb + end + + local stubs = { + stub(config, 'setup'), + stub(highlight, 'setup'), + stub(commands, 'setup'), + stub(completion, 'setup'), + stub(keymap, 'setup'), + stub(event_manager, 'setup'), + stub(context, 'setup'), + stub(context_bar, 'setup'), + stub(reference_picker, 'setup'), + stub(session_runtime, 'opencode_ok').returns(true), + } + + opencode.setup() + local first_count = #subscriptions + opencode.setup() + + for _, item in ipairs(stubs) do + if item.revert then + item:revert() + end + end + state.store.subscribe = original_subscribe + package.loaded['opencode'] = original_opencode + + assert.is_true(first_count > 0) + assert.are.equal(first_count, #subscriptions) + end) + end) + describe('select_session', function() it('filters sessions by title and parentID', function() local mock_sessions = { @@ -279,13 +296,13 @@ describe('opencode.core', function() local passed stub(ui, 'select_session').invokes(function(sessions, cb) passed = sessions - cb(sessions[2]) -- expect session3 after filtering + cb(sessions[2]) end) ui.render_output:revert() stub(ui, 'render_output') state.ui.set_windows({ input_buf = 1, output_buf = 2 }) - core.select_session(nil):wait() + session_runtime.select_session(nil):wait() assert.equal(2, #passed) assert.equal('session3', passed[2].id) assert.truthy(state.active_session) @@ -294,141 +311,10 @@ describe('opencode.core', function() end) describe('send_message', function() - it('sends a message via api_client', function() - state.ui.set_windows({ mock = 'windows' }) - state.session.set_active({ id = 'sess1' }) - - local create_called = false - local orig = state.api_client.create_message - state.api_client.create_message = function(_, sid, params) - create_called = true - assert.equal('sess1', sid) - assert.truthy(params.parts) - return Promise.new():resolve({ id = 'm1' }) - end - - core.send_message('hello world') - vim.wait(50, function() - return create_called - end) - assert.True(create_called) - state.api_client.create_message = orig - end) - - it('creates new session when none active', function() - state.ui.set_windows({ mock = 'windows' }) - state.session.set_active(nil) - - local created_session - local orig_session = state.api_client.create_session - state.api_client.create_session = function(_, _params) - created_session = true - return Promise.new():resolve({ id = 'sess-new' }) - end - - -- override create_new_session to use api_client path synchronously - local new = core.create_new_session('title'):wait() - assert.True(created_session) - assert.truthy(new) - assert.equal('sess-new', new.id) - state.api_client.create_session = orig_session - end) - - it('persist options in state when sending message', function() - local orig = state.api_client.create_message - state.ui.set_windows({ mock = 'windows' }) - state.session.set_active({ id = 'sess1' }) - - state.api_client.create_message = function(_, sid, params) - create_called = true - assert.equal('sess1', sid) - assert.truthy(params.parts) - return Promise.new():resolve({ id = 'm1' }) - end - - core.send_message( - 'hello world', - { context = { current_file = { enabled = false } }, agent = 'plan', model = 'test/model' } - ) - assert.same(state.current_context_config, { current_file = { enabled = false } }) - assert.equal(state.current_mode, 'plan') - assert.equal(state.current_model, 'test/model') - state.api_client.create_message = orig - end) - - it('increments and decrements user_message_count correctly', function() - state.ui.set_windows({ mock = 'windows' }) - state.session.set_active({ id = 'sess1' }) - state.session.set_user_message_count({}) - - -- Capture the count at different stages - local count_before = state.user_message_count['sess1'] or 0 - local count_during = nil - local count_after = nil - - local orig = state.api_client.create_message - state.api_client.create_message = function(_, sid, params) - -- Capture count while message is in flight - count_during = state.user_message_count['sess1'] - return Promise.new():resolve({ - id = 'm1', - info = { id = 'm1' }, - parts = {}, - }) - end - - core.send_message('hello world') - - -- Wait for promise to resolve - vim.wait(50, function() - count_after = state.user_message_count['sess1'] or 0 - return count_after == 0 - end) - - -- Verify: starts at 0, increments to 1, then back to 0 - assert.equal(0, count_before) - assert.equal(1, count_during) - assert.equal(0, count_after) - - state.api_client.create_message = orig - end) - - it('decrements user_message_count on error', function() - state.ui.set_windows({ mock = 'windows' }) - state.session.set_active({ id = 'sess1' }) - state.session.set_user_message_count({}) - - -- Capture the count at different stages - local count_before = state.user_message_count['sess1'] or 0 - local count_during = nil - local count_after = nil - - local orig = state.api_client.create_message - state.api_client.create_message = function(_, sid, params) - -- Capture count while message is in flight - count_during = state.user_message_count['sess1'] - return Promise.new():reject('Test error') - end - - -- Stub cancel to prevent it from trying to abort the session - local orig_cancel = core.cancel - stub(core, 'cancel') - - core.send_message('hello world') - - -- Wait for promise to reject - vim.wait(50, function() - count_after = state.user_message_count['sess1'] or 0 - return count_after == 0 - end) - - -- Verify: starts at 0, increments to 1, then back to 0 even on error - assert.equal(0, count_before) - assert.equal(1, count_during) - assert.equal(0, count_after) - - state.api_client.create_message = orig - core.cancel = orig_cancel + it('delegates message-sending coverage to services_messaging_spec', function() + -- This spec focuses on session_runtime responsibilities. + -- Message pipeline behavior is owned and asserted in services_messaging_spec.lua. + assert.is_true(true) end) end) @@ -436,7 +322,7 @@ describe('opencode.core', function() it('flushes deferred markdown render when thinking completes', function() local flush_stub = stub(flush, 'flush_pending_on_data_rendered') - core._on_user_message_count_change(nil, { sess1 = 0 }, { sess1 = 1 }):wait() + session_runtime._on_user_message_count_change(nil, { sess1 = 0 }, { sess1 = 1 }):wait() assert.stub(flush_stub).was_called() flush_stub:revert() @@ -537,7 +423,7 @@ describe('opencode.core', function() return Promise.new():resolve(true) end) - core.cancel():wait() + session_runtime.cancel():wait() assert.stub(abort_stub).was_called() assert.stub(ui.focus_input).was_not_called() @@ -582,7 +468,7 @@ describe('opencode.core', function() vim.fn.executable = function(_) return 0 end - assert.is_false(core.opencode_ok():await()) + assert.is_false(session_runtime.opencode_ok():await()) end) it('returns false when version is below required', function() @@ -592,7 +478,7 @@ describe('opencode.core', function() vim.system = mock_vim_system({ stdout = 'opencode 0.4.1' }) state.jobs.set_opencode_cli_version(nil) store.set('required_version', '0.4.2') - assert.is_false(core.opencode_ok():await()) + assert.is_false(session_runtime.opencode_ok():await()) end) it('returns true when version equals required', function() @@ -602,7 +488,7 @@ describe('opencode.core', function() vim.system = mock_vim_system({ stdout = 'opencode 0.4.2' }) state.jobs.set_opencode_cli_version(nil) store.set('required_version', '0.4.2') - assert.is_true(core.opencode_ok():await()) + assert.is_true(session_runtime.opencode_ok():await()) end) it('returns true when version is above required', function() @@ -612,18 +498,15 @@ describe('opencode.core', function() vim.system = mock_vim_system({ stdout = 'opencode 0.5.0' }) state.jobs.set_opencode_cli_version(nil) store.set('required_version', '0.4.2') - assert.is_true(core.opencode_ok():await()) + assert.is_true(session_runtime.opencode_ok():await()) end) end) describe('handle_directory_change', function() - local server_job local context before_each(function() - server_job = require('opencode.server_job') context = require('opencode.context') - stub(context, 'unload_attachments') end) @@ -635,9 +518,8 @@ describe('opencode.core', function() state.session.set_active({ id = 'old-session' }) state.session.set_last_sent_context({ some = 'context' }) - core.handle_directory_change():wait() + session_runtime.handle_directory_change():wait() - -- Should be set to the new session from get_last_workspace_session stub assert.truthy(state.active_session) assert.equal('test-session', state.active_session.id) assert.is_nil(state.last_sent_context) @@ -645,7 +527,7 @@ describe('opencode.core', function() end) it('loads last workspace session for new directory', function() - core.handle_directory_change():wait() + session_runtime.handle_directory_change():wait() assert.truthy(state.active_session) assert.equal('test-session', state.active_session.id) @@ -653,7 +535,6 @@ describe('opencode.core', function() end) it('creates new session when no last session exists', function() - -- Override stub to return nil (no last session) session.get_last_workspace_session:revert() stub(session, 'get_last_workspace_session').invokes(function() local p = Promise.new() @@ -661,7 +542,7 @@ describe('opencode.core', function() return p end) - core.handle_directory_change():wait() + session_runtime.handle_directory_change():wait() assert.truthy(state.active_session) assert.truthy(state.active_session.id) @@ -669,118 +550,13 @@ describe('opencode.core', function() end) describe('switch_to_mode', function() - it('sets current model from config file when mode has a model configured', function() - local Promise = require('opencode.promise') - local agents_promise = Promise.new() - agents_promise:resolve({ 'plan', 'build', 'custom' }) - local config_promise = Promise.new() - config_promise:resolve({ - agent = { - custom = { - model = 'anthropic/claude-3-opus', - }, - }, - model = 'gpt-4', - }) - - stub(config_file, 'get_opencode_agents').returns(agents_promise) - stub(config_file, 'get_opencode_config').returns(config_promise) - - store.set('current_mode', nil) - store.set('current_model', nil) - store.set('user_mode_model_map', {}) - - local promise = core.switch_to_mode('custom') - local success = promise:wait() - - assert.is_true(success) - assert.equal('custom', state.current_mode) - assert.equal('anthropic/claude-3-opus', state.current_model) - - config_file.get_opencode_agents:revert() - config_file.get_opencode_config:revert() - end) - - it('returns false when mode is invalid', function() - local Promise = require('opencode.promise') - local agents_promise = Promise.new() - agents_promise:resolve({ 'plan', 'build' }) - - stub(config_file, 'get_opencode_agents').returns(agents_promise) - - local promise = core.switch_to_mode('nonexistent') - local success = promise:wait() - - assert.is_false(success) - - config_file.get_opencode_agents:revert() - end) - - it('returns false when mode is empty', function() - local promise = core.switch_to_mode('') - local success = promise:wait() - assert.is_false(success) - - promise = core.switch_to_mode(nil) - success = promise:wait() - assert.is_false(success) - end) - - it('respects user_mode_model_map priority: uses model stored in mode_model_map for mode', function() - local Promise = require('opencode.promise') - local agents_promise = Promise.new() - agents_promise:resolve({ 'plan', 'build' }) - local config_promise = Promise.new() - config_promise:resolve({ - agent = { - plan = { model = 'gpt-4' }, - }, - model = 'gpt-3', - }) - stub(config_file, 'get_opencode_agents').returns(agents_promise) - stub(config_file, 'get_opencode_config').returns(config_promise) - - store.set('current_mode', nil) - store.set('current_model', 'should-be-overridden') - store.set('user_mode_model_map', { plan = 'anthropic/claude-3-haiku' }) - - local promise = core.switch_to_mode('plan') - local success = promise:wait() - assert.is_true(success) - assert.equal('plan', state.current_mode) - assert.equal('anthropic/claude-3-haiku', state.current_model) - - config_file.get_opencode_agents:revert() - config_file.get_opencode_config:revert() - end) - - it('falls back to config model if nothing else matches', function() - local Promise = require('opencode.promise') - local agents_promise = Promise.new() - agents_promise:resolve({ 'plan', 'build' }) - local config_promise = Promise.new() - config_promise:resolve({ - agent = { - plan = {}, - }, - model = 'default-model', - }) - stub(config_file, 'get_opencode_agents').returns(agents_promise) - stub(config_file, 'get_opencode_config').returns(config_promise) - store.set('current_mode', nil) - store.set('current_model', 'old-model') - store.set('user_mode_model_map', {}) - local promise = core.switch_to_mode('plan') - local success = promise:wait() - assert.is_true(success) - assert.equal('plan', state.current_mode) - assert.equal('default-model', state.current_model) - config_file.get_opencode_agents:revert() - config_file.get_opencode_config:revert() + it('delegates model/mode switch coverage to services_agent_model_spec', function() + assert.is_true(true) end) end) describe('initialize_current_model', function() + -- Keep only integration-level guardrails here; detailed behavior stays in services_agent_model_spec.lua. it('keeps the current user-selected model and mode by default', function() state.model.set_model('openai/gpt-4.1') state.model.set_mode('plan') @@ -795,7 +571,7 @@ describe('opencode.core', function() }, }) - local model = core.initialize_current_model():wait() + local model = agent_model.initialize_current_model():wait() assert.equal('openai/gpt-4.1', model) assert.equal('openai/gpt-4.1', state.current_model) @@ -816,7 +592,7 @@ describe('opencode.core', function() }, }) - local model = core.initialize_current_model({ restore_from_messages = true }):wait() + local model = agent_model.initialize_current_model({ restore_from_messages = true }):wait() assert.equal('anthropic/claude-3-opus', model) assert.equal('anthropic/claude-3-opus', state.current_model) diff --git a/tests/unit/services_spec_support.lua b/tests/unit/services_spec_support.lua new file mode 100644 index 00000000..18817d47 --- /dev/null +++ b/tests/unit/services_spec_support.lua @@ -0,0 +1,49 @@ +local state = require('opencode.state') +local store = require('opencode.state.store') +local Promise = require('opencode.promise') + +local M = {} + +function M.mock_api_client() + state.jobs.set_api_client({ + create_session = function(_, params) + return Promise.new():resolve({ id = params and params.title or 'new-session' }) + end, + get_session = function(_, id) + return Promise.new():resolve(id and { id = id, title = id, modified = os.time(), parentID = nil } or nil) + end, + create_message = function(_, sess_id, _params) + return Promise.new():resolve({ id = 'm1', sessionID = sess_id }) + end, + abort_session = function(_, _id) + return Promise.new():resolve(true) + end, + get_current_project = function() + return Promise.new():resolve({ id = 'test-project-id' }) + end, + get_config = function() + return Promise.new():resolve({ model = 'gpt-4' }) + end, + }) +end + +function M.snapshot_state() + return { + state = vim.deepcopy(state), + system = vim.system, + executable = vim.fn.executable, + schedule = vim.schedule, + } +end + +function M.restore_state(snapshot) + for k, v in pairs(snapshot.state) do + store.set(k, v) + end + + vim.system = snapshot.system + vim.fn.executable = snapshot.executable + vim.schedule = snapshot.schedule +end + +return M