From c5b8ca0f29a17e95d0609d49aad2e77eec59cb18 Mon Sep 17 00:00:00 2001 From: jensenojs Date: Tue, 20 Oct 2026 14:10:35 +0800 Subject: [PATCH 01/11] fix(log): resolve the log destination when logging, not at module load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup() configures logging.outfile and logging.level, but the module captured both when it loaded. Anything requiring opencode.log before setup runs — a plugin spec's top-level require, for instance — pinned the default path and threshold, so the configured file stayed empty and debug lines were dropped without a trace. Read the path and the threshold per call. --- lua/opencode/log.lua | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/lua/opencode/log.lua b/lua/opencode/log.lua index ba1b8fca..734fc7b0 100644 --- a/lua/opencode/log.lua +++ b/lua/opencode/log.lua @@ -2,9 +2,11 @@ local M = {} local config = require('opencode.config') -local log_path = (config.logging and config.logging.outfile) or vim.fn.stdpath('log') .. '/opencode.log' local log_levels = { debug = 1, info = 2, warn = 3, error = 4 } -local current_level = log_levels[(config.logging and config.logging.level or 'warn'):lower()] or 3 + +local function log_path() + return (config.logging and config.logging.outfile) or vim.fn.stdpath('log') .. '/opencode.log' +end local function caller_info() local level = 2 @@ -22,10 +24,12 @@ local function caller_info() end local function log(level, msg, ...) - if not config.logging.enabled then + local settings = config.logging or {} + if not settings.enabled then return end - if log_levels[level] < current_level then + local threshold = log_levels[(settings.level or 'warn'):lower()] or log_levels.warn + if log_levels[level] < threshold then return end local line = string.format( @@ -35,7 +39,7 @@ local function log(level, msg, ...) caller_info(), string.format(msg, ...) ) - local file = io.open(log_path, 'a') + local file = io.open(log_path(), 'a') if file then file:write(line) file:close() @@ -56,7 +60,7 @@ function M.error(msg, ...) end function M.get_path() - return config.logging.enabled and log_path or nil + return config.logging and config.logging.enabled and log_path() or nil end --- Emit a user-visible notification and write the same message to the log. From 3be01efa0e27ad89dc37ca1a51a72c8419b6d8b2 Mon Sep 17 00:00:00 2001 From: jensenojs Date: Tue, 20 Oct 2026 14:10:48 +0800 Subject: [PATCH 02/11] fix(session): create the session on the first prompt, not on open Opening the panel created a server session so the panel had something to show. That session carried no title and no tokens; nothing distinguished it from one a user had worked in. The tab layout snapshot stored it all the same, so the next open restored it and displayed "New session". Leave the tab empty instead. messaging.send_message creates the session when the first prompt arrives, waits until the session resource reports current, and submits the mode, model, context and variant captured at submit time, so a change made in another tab during that wait cannot redirect the prompt. The snapshot stores only tabs that carry a session, and its write names the stored ids, the active index and the trigger (exit, change , group switch, manual); a snapshot lookup that fails names the session id it could not read. --- lua/opencode/services/agent_model.lua | 2 +- lua/opencode/services/messaging.lua | 218 ++++++++++++++++-- lua/opencode/services/session_runtime.lua | 15 +- .../services/session_tab_persistence.lua | 159 +++++++++++-- tests/unit/services_messaging_spec.lua | 74 +++++- tests/unit/services_session_runtime_spec.lua | 5 +- tests/unit/session_tab_persistence_spec.lua | 142 ++++++++++-- 7 files changed, 553 insertions(+), 62 deletions(-) diff --git a/lua/opencode/services/agent_model.lua b/lua/opencode/services/agent_model.lua index f1dd7e4c..507f5d96 100644 --- a/lua/opencode/services/agent_model.lua +++ b/lua/opencode/services/agent_model.lua @@ -303,7 +303,7 @@ function M.sync_from_server() M.apply_server_selection(fact and fact.model) end) :catch(function(err) - log.debug('Failed to sync session model from server', { error = err }) + log.warn('Failed to sync session model from server: %s', tostring(err)) end) end diff --git a/lua/opencode/services/messaging.lua b/lua/opencode/services/messaging.lua index debb57c4..c52c6ad5 100644 --- a/lua/opencode/services/messaging.lua +++ b/lua/opencode/services/messaging.lua @@ -12,6 +12,13 @@ local optimistic = require('opencode.state.optimistic') local M = {} +---@type table +local draft_creations = {} +local next_draft_token = 0 +---changedtick covers native completion, which does not fire TextChangedI while the popup is visible. +---@type table +local input_ticks = {} + ---@param tab_id? string ---@param submission_context OpencodeContext local function consume_sent_attachments(tab_id, submission_context) @@ -36,33 +43,56 @@ end ---@field selected_model {mode?: string, model?: string, variant?: string} ---@field model_update OpencodeSessionTabModelUpdate +---@class SubmissionSnapshot +---@field mode string|nil +---@field model string|nil +---@field variant string|nil +---@field context_config OpencodeContextConfig|nil +---@field context OpencodeContext +---@field last_sent_context OpencodeContext|nil +---@field session_id string|nil + ---@param observation OpencodeObservation ---@param prompt string ---@param opts SendMessageOpts +---@param snapshot? SubmissionSnapshot ---@return PreparedMessage -local function prepare_message(observation, prompt, opts) - local selected_model = { - mode = state.current_mode or config.default_mode, - model = state.current_model, - variant = state.current_variant, - } +local function prepare_message(observation, prompt, opts, snapshot) + -- Replace wholesale: per-field fallbacks would adopt values another tab changed during the wait. + local selected_model = snapshot + and { + mode = snapshot.mode or config.default_mode, + model = snapshot.model, + variant = snapshot.variant, + } + or { + mode = state.current_mode or config.default_mode, + model = state.current_model, + variant = state.current_variant, + } observation:validate_message_options(opts, config.default_system_prompt) - 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() + local base_config = snapshot and snapshot.context_config or (not snapshot and state.current_context_config or nil) + opts.context = vim.tbl_deep_extend('force', {}, base_config or {}, opts.context or {}) + if not snapshot then + state.context.set_current_context_config(opts.context) + context.load() + end - local submission_context = vim.deepcopy(context.get_context()) + local submission_context = vim.deepcopy(snapshot and snapshot.context or context.get_context()) + local session_id = snapshot and snapshot.session_id + or (not snapshot and state.active_session and state.active_session.id or nil) submission_context.review_comments = vim.tbl_filter(function(comment) - return comment.session_id == state.active_session.id + return comment.session_id == session_id end, submission_context.review_comments or {}) submission_context.automatic_context = {} - local previous_context = state.last_sent_context and vim.deepcopy(state.last_sent_context) + local previous_source = snapshot and snapshot.last_sent_context or (not snapshot and state.last_sent_context or nil) + local previous_context = previous_source and vim.deepcopy(previous_source) ---@type OpencodeMessageSelection local selected = { - mode = state.current_mode, - model = state.current_model, - variant = state.current_variant, + mode = snapshot and snapshot.mode or (not snapshot and state.current_mode or nil), + model = snapshot and snapshot.model or (not snapshot and state.current_model or nil), + variant = snapshot and snapshot.variant or (not snapshot and state.current_variant or nil), default_mode = config.default_mode, } if observation.needs_message_defaults then @@ -141,25 +171,162 @@ local function submit_message(observation, prompt, tab_id, session_id, prepared, return result end +---Releasing the subscription early restores the session resource to unread, and the submit that follows is rejected. +---@param tab_id string +---@param token string Identifies this attempt; a newer attempt on the same tab invalidates it +---@return OpencodeObservation|nil, SubmissionSnapshot|nil, fun()|nil +local function materialize_draft_session(tab_id, token) + local runtime = session_tabs.get(tab_id) + if not runtime or runtime.active_session then + return nil, nil, nil + end + session_tabs.sync(tab_id) + local snapshot = { + mode = runtime.current_mode, + model = runtime.current_model, + variant = runtime.current_variant, + context_config = vim.deepcopy(runtime.current_context_config), + context = vim.deepcopy(runtime.context_data or context.get_context()), + last_sent_context = runtime.last_sent_context and vim.deepcopy(runtime.last_sent_context), + session_id = nil, + } + + local ready = Promise.new() + local unsubscribe + local cancelled = false + local function release() + cancelled = true + if unsubscribe then + unsubscribe() + end + ready:resolve(false) + end + draft_creations[tab_id] = { token = token, release = release } + runtime._draft_release = release + + local creation = session_runtime.create_detached_session():catch(function(err) + log.warn('draft session creation failed: %s', tostring(err)) + return nil + end) + local gate = Promise.new() + creation:and_then(function(value) + gate:resolve(value) + end) + ready:and_then(function() + gate:resolve(nil) + end) + local detached = gate:await() + local entry = draft_creations[tab_id] + local owner = session_tabs.get(tab_id) + if cancelled or not detached or not owner or owner.active_session or not entry or entry.token ~= token then + release() + return nil, nil, nil + end + local observation = detached.observation + unsubscribe = observation:watch({ 'session' }, function(observed) + local state_now = observed:read() + local sync = state_now.sync and state_now.sync.session + if sync and (sync.state == 'current' or sync.state == 'error') then + ready:resolve(sync.state == 'current') + end + end) + local became_ready = ready:await() + entry = draft_creations[tab_id] + if not became_ready or not entry or entry.token ~= token then + release() + return nil, nil, nil + end + owner = session_tabs.get(tab_id) + if not owner or owner.active_session then + release() + return nil, nil, nil + end + + local session = detached.session + snapshot.session_id = session.id + if session_tabs.active_id() == tab_id then + state.session.set_active(session) + else + owner.active_session = session + end + return observation, snapshot, release +end + +---The active tab's buffer and store are the authority; writing the parked runtime alone is overwritten by the next sync. +---@param tab_id string +---@param prompt string +local function restore_prompt(tab_id, prompt) + local lines = vim.split(prompt, '\n', { plain = true }) + if session_tabs.active_id() == tab_id then + state.ui.set_input_content(lines) + require('opencode.ui.input_window').set_content(lines) + return + end + local runtime = session_tabs.get(tab_id) + if runtime then + runtime.input_content = lines + end +end + --- 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) local tab_id = state.active_session_tab local observation = state.session.active_observation() + local snapshot + local release_draft if not observation then + if not tab_id then + return false + end + local previous = draft_creations[tab_id] + if previous then + previous.release() + end + next_draft_token = next_draft_token + 1 + local token = 'draft-' .. next_draft_token + local windows = state.windows + local tick = windows and windows.input_buf and vim.api.nvim_buf_get_changedtick(windows.input_buf) + input_ticks[tab_id] = tick + observation, snapshot, release_draft = materialize_draft_session(tab_id, token) + local entry = draft_creations[tab_id] + if entry and entry.token == token then + draft_creations[tab_id] = nil + end + local runtime = session_tabs.get(tab_id) + if runtime and runtime._draft_release == release_draft then + runtime._draft_release = nil + end + if not observation then + local buf = windows and windows.input_buf + local buf_alive = buf and vim.api.nvim_buf_is_valid(buf) + local current_tick = buf_alive and vim.api.nvim_buf_get_changedtick(buf) + if entry and entry.token == token and buf_alive and current_tick == input_ticks[tab_id] then + restore_prompt(tab_id, prompt) + end + return false + end + end + + local function abandon(reason) + if release_draft then + release_draft() + end + if reason then + log.notify(reason, vim.log.levels.WARN) + end return false end local observed = observation:read() local session_fact = observed.session if not session_fact or not observed.sync or not observed.sync.session or observed.sync.session.state ~= 'current' then - log.notify('Session metadata is not ready', vim.log.levels.WARN) - return false + return abandon('Session metadata is not ready') end if session_fact.parentID and config.child_readonly then - return false + return abandon() end local mentioned_files = context.get_context().mentioned_files or {} @@ -167,22 +334,29 @@ M.send_message = Promise.async(function(prompt, opts) if not allowed then log.notify(err_msg or 'Prompt denied by prompt_guard', vim.log.levels.ERROR) - return + return abandon() end local server = state.opencode_server if not server then log.notify('Not connected to OpenCode server', vim.log.levels.ERROR) - return false + return abandon() end local pending = optimistic.begin(observation, prompt) - local prepared_ok, prepared = pcall(prepare_message, observation, prompt, vim.deepcopy(opts or {})) + local prepared_ok, prepared = pcall(prepare_message, observation, prompt, vim.deepcopy(opts or {}), snapshot) if not prepared_ok then optimistic.finish(observation, pending, 'failed') + if release_draft then + release_draft() + end error(prepared, 0) end - return submit_message(observation, prompt, tab_id, session_fact.id, prepared, pending) + local result = submit_message(observation, prompt, tab_id, session_fact.id, prepared, pending) + if release_draft then + release_draft() + end + return result end) ---@param name string Skill name from the server catalog diff --git a/lua/opencode/services/session_runtime.lua b/lua/opencode/services/session_runtime.lua index b2dd6338..1d6d6270 100644 --- a/lua/opencode/services/session_runtime.lua +++ b/lua/opencode/services/session_runtime.lua @@ -89,7 +89,7 @@ local function observe_active_session() end end) :catch(function(err) - log.debug('Failed to restore session model', { session_id = session_id, error = err }) + log.warn('Failed to restore session model for %s: %s', session_id, tostring(err)) end) :finally(function() binding.restoring_model = false @@ -452,12 +452,15 @@ M.open = Promise.async(function(opts) else agent_model.ensure_current_mode():await() if not state.active_session then - if not config.ui.restore_tabs_on_startup then - state.session.set_active(last_workspace_session():await()) - end - if not state.active_session then - state.session.set_active(M.create_new_session():await()) + local resumed, recent = pcall(function() + return last_workspace_session():await() + end) + if not resumed then + log.warn('Failed to resume the most recent session: ' .. tostring(recent)) + elseif recent then + state.session.set_active(recent) end + -- No server session here: the first prompt creates one (messaging.send_message). elseif not state.display_route and created_windows and ui.is_output_empty() then ui.render_output() end diff --git a/lua/opencode/services/session_tab_persistence.lua b/lua/opencode/services/session_tab_persistence.lua index cbfdefce..011d00e9 100644 --- a/lua/opencode/services/session_tab_persistence.lua +++ b/lua/opencode/services/session_tab_persistence.lua @@ -27,6 +27,12 @@ local requested_cwd local requested_server ---@type Promise|nil local pending_restore +---True while restore replays an on-disk layout into memory; those mutations +---must not bounce back to disk. +local replaying_restore = false +---Debounces persistence after a durable layout change. +---@type uv_timer_t|nil +local save_timer ---@class OpencodeSavedSessionTab ---@field session_id string @@ -80,11 +86,17 @@ function M.path(project, server) return vim.fs.joinpath(vim.fn.stdpath('state'), 'opencode', 'session-tabs', key .. '.json') end +---Last snapshot this process read or wrote per path. A layout that still matches +---it has nothing new to persist, so another instance's newer snapshot is left +---alone. +---@type table +local synced = {} + ---Invalid snapshots read as missing, so a bad file never breaks opening the panel. ---@param project string ---@param server string ---@return OpencodeSessionTabSnapshot|nil -function M.load(project, server) +local function read_snapshot(project, server) local file = io.open(M.path(project, server), 'r') if not file then return nil @@ -98,28 +110,51 @@ function M.load(project, server) return snapshot --[[@as OpencodeSessionTabSnapshot]] end +---@param project string +---@param server string +---@return OpencodeSessionTabSnapshot|nil +function M.load(project, server) + local snapshot = read_snapshot(project, server) + if snapshot then + synced[M.path(project, server)] = snapshot + end + return snapshot +end + ---Write atomically; readers never see a partially written snapshot. ---@param snapshot OpencodeSessionTabSnapshot +---@param reason string ---@return boolean, string|nil -local function write(snapshot) +local function write(snapshot, reason) local path = M.path(snapshot.project, snapshot.server) + local encoded = vim.json.encode(snapshot) + if synced[path] and vim.deep_equal(synced[path], snapshot) then + return true + end local temporary = path .. '.' .. vim.fn.getpid() .. '.tmp' local ok, err = pcall(function() vim.fn.mkdir(vim.fs.dirname(path), 'p') - assert(vim.fn.writefile({ vim.json.encode(snapshot) }, temporary) == 0, 'Failed to write session tabs') + assert(vim.fn.writefile({ encoded }, temporary) == 0, 'Failed to write session tabs') assert(os.rename(temporary, path)) end) if not ok then os.remove(temporary) return false, tostring(err) end + synced[path] = snapshot + local ids = {} + for _, tab in ipairs(snapshot.tabs) do + table.insert(ids, tab.session_id) + end + log.debug('session tabs written for %s: %s active_index=%d', reason, table.concat(ids, ','), snapshot.active_index) return true end ---@param key string ---@param group OpencodeTabGroup +---@param reason string ---@return boolean -local function save_group(key, group) +local function save_group(key, group, reason) local cwd, server = group.cwd, group.server ---@type OpencodeSessionTabSnapshot local snapshot = { version = 2, project = group.project, cwd = cwd, server = server, tabs = {}, active_index = 0 } @@ -138,7 +173,37 @@ local function save_group(key, group) end end end - local ok, err = write(snapshot) + if group.failed then + local disk = read_snapshot(group.project, server) + if disk then + local merged = { version = 2, project = group.project, cwd = cwd, server = server, tabs = {}, active_index = 0 } + local seen = {} + for _, tab in ipairs(disk.tabs) do + seen[tab.session_id] = true + table.insert(merged.tabs, tab) + end + local our_active + for index, tab in ipairs(snapshot.tabs) do + if not seen[tab.session_id] then + seen[tab.session_id] = true + table.insert(merged.tabs, tab) + end + if index == snapshot.active_index then + our_active = tab.session_id + end + end + for index, tab in ipairs(merged.tabs) do + if tab.session_id == our_active then + merged.active_index = index + end + end + if merged.active_index == 0 then + merged.active_index = math.min(snapshot.active_index, #merged.tabs) + end + snapshot = merged + end + end + local ok, err = write(snapshot, reason) if not ok then log.warn('Failed to persist session tabs: ' .. tostring(err)) end @@ -189,7 +254,8 @@ local restore_snapshot = Promise.async(function(snapshot, current, cwd, backgrou { directory = entry.directory }, util.apply_path_map, util.apply_reverse_path_map - ):catch(function() + ):catch(function(err) + log.warn('session lookup failed for %s: %s', entry.session_id, tostring(err)) return nil end) ) @@ -223,8 +289,21 @@ local restore_snapshot = Promise.async(function(snapshot, current, cwd, backgrou end -- Background restores keep the current tab active for the explicit new session. if #restored > 0 and not background then - tabs.remove(current) - tabs.activate(active or restored[1]) + -- Replaying the snapshot must not rewrite a file another instance may have changed. + replaying_restore = true + if save_timer then + save_timer:stop() + end + local ok, err = pcall(function() + tabs.remove(current) + tabs.activate(active or restored[1]) + end) + vim.schedule(function() + replaying_restore = false + end) + if not ok then + error(err) + end end end) @@ -242,14 +321,17 @@ end ---@return Promise|nil function M.restore(background, before_switch, directory) if not config.ui.restore_tabs_on_startup then + log.debug('restore skip: restore_tabs_on_startup is off') return nil end local cwd = vim.fs.normalize(directory or vim.fn.getcwd()) local server = server_identity() if requested_cwd == cwd and requested_server == server and pending_restore and not pending_restore:is_resolved() then + log.debug('restore reuse: pending request', { cwd = cwd }) return pending_restore end if requested_cwd == cwd and requested_server == server and owner and owner.attempted and not owner.failed then + log.debug('restore skip: group already attempted', { cwd = cwd }) return nil end requested_cwd = cwd @@ -285,7 +367,7 @@ function M.restore(background, before_switch, directory) local previous = owner if owner ~= group then if previous then - M.save() + M.save('group switch') if previous.restoring then previous.attempted = false end @@ -301,16 +383,19 @@ function M.restore(background, before_switch, directory) group.attempted = false end if group.attempted and not group.failed then + log.debug('restore skip: group attempted inside promise', { key = key }) return end group.attempted = true group.failed = false local current = tabs.list() if not untouched(current, background) then + log.debug('restore skip: current tab is no longer untouched', { tabs = #current }) return end local snapshot = M.load(identity, server) if not snapshot or #snapshot.tabs == 0 then + log.debug('restore skip: no snapshot', { key = key, identity = identity }) return end group.restoring = true @@ -339,20 +424,61 @@ end ---Save only durable session references; never buffers, drafts or permission state. ---Returns false on disk failure (logged), leaving the previous snapshot intact. +---@param reason? string Why the snapshot is being written; lands in the debug log. ---@return boolean -function M.save() +function M.save(reason) + reason = reason or 'manual' if not config.ui.restore_tabs_on_startup then return true end local success = true for key, group in pairs(groups) do - if group.attempted and not group.failed and not group.restoring then - success = save_group(key, group) and success + if not group.restoring then + local healthy = group.attempted and not group.failed + local has_live = false + for _, runtime in ipairs(tabs.list(key)) do + if runtime.active_session then + has_live = true + break + end + end + -- An empty state still saves, so closed tabs do not resurrect; a failed restore saves only + -- once the user holds sessions, so it cannot blank a good snapshot. + if healthy or has_live then + success = save_group(key, group, reason) and success + end end end return success end +---Persist the current layout soon after a durable change (session switch, tab +---open/close). Exit-only saving loses everything when the instance that holds +---the real work stays open while other instances keep writing stale layouts. +local pending_reason = 'change' + +---@param key? string Store key that changed. +local function schedule_save(key) + pending_reason = key and ('change ' .. key) or 'change' + if replaying_restore then + return + end + if not save_timer then + save_timer = vim.uv.new_timer() + end + save_timer:stop() + save_timer:start( + 300, + 0, + vim.schedule_wrap(function() + if not owner then + return + end + M.save(pending_reason) + end) + ) +end + function M.setup() groups = {} owner = nil @@ -360,10 +486,17 @@ function M.setup() requested_cwd = nil requested_server = nil pending_restore = nil + replaying_restore = false + synced = {} + if save_timer then + save_timer:stop() + end + local store = require('opencode.state.store') + store.subscribe({ 'active_session', 'session_tabs_changed' }, schedule_save) vim.api.nvim_create_autocmd('VimLeavePre', { group = vim.api.nvim_create_augroup('OpencodeSessionTabPersistence', { clear = true }), callback = function() - M.save() + M.save('exit') end, }) end diff --git a/tests/unit/services_messaging_spec.lua b/tests/unit/services_messaging_spec.lua index 583ba33c..a3d23696 100644 --- a/tests/unit/services_messaging_spec.lua +++ b/tests/unit/services_messaging_spec.lua @@ -155,12 +155,84 @@ describe('opencode.services.messaging', function() format:revert() end) - it('returns false when active session is missing', function() + it('creates a session on the first prompt when the tab is a draft', function() state.ui.set_windows({ mock = 'windows' }) state.session.set_active(nil) + local tab = session_tabs.ensure_current() + local submitted + local observation = { + _state = { + session = { id = 'draft-session' }, + sync = { session = { state = 'unread' } }, + }, + read = function(self) + return self._state + end, + watch = function(self, _, changed) + self._state.sync.session = { state = 'current' } + changed(self) + return function() end + end, + submit = function() + submitted = true + return successful_submission() + end, + validate_message_options = function() end, + prepare_message = function() + return {}, {} + end, + } + local session_runtime = require('opencode.services.session_runtime') + local create = stub(session_runtime, 'create_detached_session').returns(Promise.new():resolve({ + session = { id = 'draft-session' }, + observation = observation, + })) + + local sent = messaging.send_message('hello world') + assert.is_true(vim.wait(1000, function() + return sent:is_resolved() + end)) + assert.equals('draft-session', state.active_session.id) + assert.is_true(submitted) + assert.stub(create).was_called(1) + create:revert() + session_tabs.reset() + tab = nil + end) + + it('returns false and keeps the prompt when the draft session cannot be created', function() + local buf = vim.api.nvim_create_buf(false, true) + state.ui.set_windows({ input_buf = buf }) + state.session.set_active(nil) + local tab = session_tabs.ensure_current() + + local session_runtime = require('opencode.services.session_runtime') + local create = stub(session_runtime, 'create_detached_session').returns(Promise.new():reject('create failed')) local sent = messaging.send_message('hello world'):wait() assert.is_false(sent) + assert.same({ 'hello world' }, state.input_content) + create:revert() + session_tabs.reset() + end) + + it('returns false without error when the draft tab is closed during creation', function() + local buf = vim.api.nvim_create_buf(false, true) + state.ui.set_windows({ input_buf = buf }) + state.session.set_active(nil) + local tab = session_tabs.ensure_current() + + local session_runtime = require('opencode.services.session_runtime') + local gate = Promise.new() + local create = stub(session_runtime, 'create_detached_session').returns(gate) + local sent = messaging.send_message('hello world') + session_tabs.remove(tab) + vim.api.nvim_buf_delete(buf, { force = true }) + gate:resolve(nil) + local result = sent:wait() + assert.is_false(result) + create:revert() + session_tabs.reset() end) it('does not submit before the active session fact is current', function() diff --git a/tests/unit/services_session_runtime_spec.lua b/tests/unit/services_session_runtime_spec.lua index c69b1ef5..3ad06557 100644 --- a/tests/unit/services_session_runtime_spec.lua +++ b/tests/unit/services_session_runtime_spec.lua @@ -387,7 +387,7 @@ describe('opencode.services.session_runtime', function() assert.is_true(output_focused) end) - it('creates a new session when no active session and no last session exists', function() + it('leaves the tab empty when no active session and no last session exists', function() state.ui.set_windows(nil) state.session.set_active(nil) state.opencode_server.operations.list_sessions_project = function() @@ -396,8 +396,7 @@ describe('opencode.services.session_runtime', function() session_runtime.open({ new_session = false, focus = 'input' }):wait() - assert.truthy(state.active_session) - assert.truthy(state.active_session.id) + assert.is_nil(state.active_session) end) it('resets is_opening flag when error occurs', function() diff --git a/tests/unit/session_tab_persistence_spec.lua b/tests/unit/session_tab_persistence_spec.lua index 311f8b57..99015c84 100644 --- a/tests/unit/session_tab_persistence_spec.lua +++ b/tests/unit/session_tab_persistence_spec.lua @@ -4,6 +4,7 @@ local config = require('opencode.config') local server_job = require('opencode.server_job') local service = require('opencode.services.session_tab_persistence') local Promise = require('opencode.promise') +local log = require('opencode.log') local stub = require('luassert.stub') describe('session tab persistence', function() @@ -144,6 +145,20 @@ describe('session tab persistence', function() assert.same(saved, vim.fn.readfile(snapshot_path())) end) + it('persists a session switch without waiting for exit', function() + save_tabs() + restart() + restore() + local before = vim.fn.readfile(snapshot_path()) + state.session.set_active({ id = 'third', title = 'Third' }) + local persisted = vim.wait(1000, function() + local saved = vim.json.decode(table.concat(vim.fn.readfile(snapshot_path()), '\n')) + return saved.tabs[#saved.tabs].session_id == 'third' + end) + assert.is_true(persisted) + assert.are_not.same(before, vim.fn.readfile(snapshot_path())) + end) + it('shares an in-flight restore and does not overwrite its snapshot on exit', function() save_tabs() local saved = vim.fn.readfile(snapshot_path()) @@ -163,6 +178,30 @@ describe('session tab persistence', function() assert.is_nil(service.restore(false)) end) + it('does not overwrite the snapshot while a restore with a live session is in flight', function() + save_tabs() + local saved = vim.fn.readfile(snapshot_path()) + restart() + local lookups = {} + connection.operations.get_session = function() + local lookup = Promise.new() + table.insert(lookups, lookup) + return lookup + end + state.session.set_active({ id = 'explicit' }) + local restoring = service.restore(true) + assert.is_not_nil(restoring) + assert.is_true(vim.wait(1000, function() + return #lookups > 0 + end)) + service.save() + assert.same(saved, vim.fn.readfile(snapshot_path())) + for index, lookup in ipairs(lookups) do + lookup:resolve({ id = index == 1 and 'first' or 'second' }) + end + restoring:wait() + end) + it('does not replace a session selected while saved sessions are loading', function() save_tabs() restart() @@ -228,6 +267,32 @@ describe('session tab persistence', function() assert.equals(2, #tabs.list()) end) + it('keeps sessions from the snapshot when a failed restore later persists new work', function() + save_tabs() + restart() + server_stub:revert() + local calls = 0 + server_stub = stub(server_job, 'ensure_server', function() + calls = calls + 1 + if calls == 1 then + return Promise.new():resolve(connection) + end + return Promise.new():reject('startup failed') + end) + local restoring = service.restore(false) + assert.is_not_nil(restoring) + restoring:wait() + state.session.set_active({ id = 'explicit', title = 'Explicit' }) + assert.is_true(service.save()) + local saved = vim.json.decode(table.concat(vim.fn.readfile(snapshot_path()), '\n')) + assert.same({ 'first', 'second', 'explicit' }, { + saved.tabs[1].session_id, + saved.tabs[2].session_id, + saved.tabs[3].session_id, + }) + assert.equals(3, saved.active_index) + end) + it('skips deleted sessions and falls back to the first surviving active tab', function() save_tabs() restart() @@ -242,6 +307,32 @@ describe('session tab persistence', function() assert.equals('first', tabs.current().active_session.id) end) + it('logs the session id and error when a restore lookup fails', function() + save_tabs() + restart() + connection.operations.get_session = function(_, id, location) + if id == 'second' then + return Promise.new():reject('get_session failed: 404') + end + return Promise.new():resolve({ id = id, location = location }) + end + local warned = stub(log, 'warn').invokes(function() end) + restore() + assert.stub(warned).was_called_with('session lookup failed for %s: %s', 'second', 'get_session failed: 404') + warned:revert() + end) + + it('records which session ids a save wrote', function() + save_tabs() + restart() + restore() + tabs.activate(tabs.list()[1]) + local logged = stub(log, 'debug').invokes(function() end) + assert.is_true(service.save()) + assert.stub(logged).was_called_with('session tabs written for %s: %s active_index=%d', 'manual', 'first,second', 1) + logged:revert() + end) + it('skips missing directories and preserves the empty fallback tab when none survive', function() save_tabs() local path = snapshot_path() @@ -287,6 +378,7 @@ describe('session tab persistence', function() save_tabs() local path = snapshot_path() local saved = vim.fn.readfile(path) + tabs.activate(tabs.list()[1]) local rename = stub(os, 'rename').returns(nil, 'rename failed') local ok = service.save() rename:revert() @@ -359,7 +451,7 @@ describe('session tab persistence', function() enter('other-project', temporary) local path_b = service.path('other-project', a.server) assert.equals('test-project', vim.json.decode(table.concat(vim.fn.readfile(path_a), '\n')).project) - state.session.set_active({ id = 'other', location = { directory = temporary } }) + state.session.set_active({ id = 'other', title = 'Other', location = { directory = temporary } }) service.save() assert.equals('other-project', vim.json.decode(table.concat(vim.fn.readfile(path_b), '\n')).project) restart() @@ -497,6 +589,9 @@ describe('session tab persistence', function() connection.operations.create_session = function(_, location) return Promise.new():resolve({ id = 'default-session', location = location }) end + connection.operations.list_sessions_project = function() + return Promise.new():resolve({}) + end end) after_each(function() @@ -512,8 +607,7 @@ describe('session tab persistence', function() local previous = tabs.current() cwd = temporary runtime.handle_directory_change(cwd):wait() - assert.equals('default-session', state.active_session.id) - assert.equals(temporary, state.active_session.location.directory) + assert.is_nil(state.active_session) assert.equals(1, #tabs.list()) assert.equals('second', previous.active_session.id) assert.stub(ui.hide_visible_windows).was_called(1) @@ -524,32 +618,48 @@ describe('session tab persistence', function() assert.equals('second', state.active_session.id) end) - it('creates a default session on first open without a snapshot and reuses it on reopen', function() + it('leaves a draft tab on first open without a snapshot and creates nothing', function() cwd = temporary - local create = stub(connection.operations, 'create_session').invokes(function(_, location) - return Promise.new():resolve({ id = 'fresh', location = location }) - end) + local create = stub(connection.operations, 'create_session') table.insert(runtime_stubs, create) runtime.open({ new_session = false }):wait() - assert.equals('fresh', state.active_session.id) - assert.equals(temporary, state.active_session.location.directory) + assert.is_nil(state.active_session) assert.equals(1, #tabs.list()) runtime.open({ new_session = false }):wait() - assert.stub(create).was_called(1) + assert.is_nil(state.active_session) + assert.equals(1, #tabs.list()) + assert.stub(create).was_not_called() end) - it('defers default-session creation for a hidden incoming group until the panel opens', function() + it('keeps an empty tab for a hidden incoming group until the first prompt', function() save_tabs() table.insert(runtime_stubs, stub(state.ui, 'is_visible').returns(false)) cwd = temporary runtime.handle_directory_change(cwd):wait() assert.is_nil(state.active_session) runtime.open({ new_session = false }):wait() - assert.equals('default-session', state.active_session.id) - assert.equals(temporary, state.active_session.location.directory) + assert.is_nil(state.active_session) assert.equals(1, #tabs.list()) end) + it('resumes the most recent project session when the snapshot yields nothing', function() + local create = stub(connection.operations, 'create_session') + table.insert(runtime_stubs, create) + connection.operations.list_sessions_project = function() + return Promise.new():resolve({ + { + id = 'recent', + title = 'Recent work', + time = { updated = 200 }, + location = { directory = vim.fn.getcwd() }, + }, + }) + end + runtime.open({ new_session = false }):wait() + assert.equals('recent', state.active_session.id) + assert.stub(create).was_not_called() + end) + it('keeps restored sessions without creating an extra default session', function() save_tabs() restart() @@ -563,7 +673,7 @@ describe('session tab persistence', function() for _, empty_snapshot in ipairs({ false, true }) do it( - 'creates a default session when ' .. (empty_snapshot and 'the snapshot is empty' or 'no saved sessions survive'), + 'opens a draft tab when ' .. (empty_snapshot and 'the snapshot is empty' or 'no saved sessions survive'), function() save_tabs() if empty_snapshot then @@ -578,7 +688,7 @@ describe('session tab persistence', function() end restart() runtime.open({ new_session = false }):wait() - assert.equals('default-session', state.active_session.id) + assert.is_nil(state.active_session) assert.equals(1, #tabs.list()) end ) @@ -595,7 +705,7 @@ describe('session tab persistence', function() assert.equals(2, #tabs.list()) runtime.set_session_lock(false) runtime.open({ new_session = false, open_action = 'restore_hidden' }):wait() - assert.equals('default-session', state.active_session.id) + assert.is_nil(state.active_session) assert.equals(1, #tabs.list()) end) end) From 0afae545f6a097506e8eec78a751771a2689a013 Mon Sep 17 00:00:00 2001 From: jensenojs Date: Tue, 20 Oct 2026 14:10:57 +0800 Subject: [PATCH 03/11] fix(observability): report failures that used to return silently The "New session" report could not be traced: the snapshot lookup that failed and the write that stored the empty session both returned without a word. A logged outcome is the difference between a guess and a lead. Cover the boundaries that decide the panel's content: observation resource loads, stream failures and event routing; contract checks; port mapping loads; session part lookups; store listeners; history reads and writes. --- lua/opencode/history.lua | 13 +++++++++++-- lua/opencode/port_mapping.lua | 6 +++++- lua/opencode/protocols/contract_check.lua | 10 ++++++++-- lua/opencode/protocols/observation.lua | 15 ++++++++++++++- lua/opencode/state/session_tabs.lua | 8 +++++++- lua/opencode/state/store.lua | 7 +++++-- tests/unit/observation_logging_spec.lua | 16 ++++++++++++++++ tests/unit/protocol_v2_contract_spec.lua | 10 +++++++--- 8 files changed, 73 insertions(+), 12 deletions(-) diff --git a/lua/opencode/history.lua b/lua/opencode/history.lua index 539d6211..9bd940a3 100644 --- a/lua/opencode/history.lua +++ b/lua/opencode/history.lua @@ -1,3 +1,5 @@ +local log = require('opencode.log') + local M = {} local cached_history = nil @@ -26,12 +28,17 @@ M.write = function(prompt) end local file = io.open(path, 'a') if not file then + log.warn('history: could not open %s for appending', path) return false end local written = file:write(vim.json.encode(prompt) .. '\n') local closed = file:close() cached_history = nil - return written ~= nil and closed ~= nil + if written == nil or closed == nil then + log.warn('history: failed to append to %s', path) + return false + end + return true end ---@return string[] @@ -49,7 +56,9 @@ M.read = function() for line in file:lines() do if is_json then local ok, prompt = pcall(vim.json.decode, line) - if ok and type(prompt) == 'string' then + if not ok then + log.warn('history: skipping undecodable line: %s', line) + elseif type(prompt) == 'string' then lines[#lines + 1] = prompt end elseif line:find('%S') then diff --git a/lua/opencode/port_mapping.lua b/lua/opencode/port_mapping.lua index 63cd5970..5a529a27 100644 --- a/lua/opencode/port_mapping.lua +++ b/lua/opencode/port_mapping.lua @@ -32,7 +32,11 @@ local function load() local content = file:read('*all') file:close() local ok, data = pcall(vim.json.decode, content or '') - return ok and data or {} + if not ok then + log.warn('port_mapping: discarding unreadable %s: %s', file_path(), tostring(data)) + return {} + end + return data end --- @param mappings table diff --git a/lua/opencode/protocols/contract_check.lua b/lua/opencode/protocols/contract_check.lua index f75a4e21..31c5c256 100644 --- a/lua/opencode/protocols/contract_check.lua +++ b/lua/opencode/protocols/contract_check.lua @@ -45,7 +45,12 @@ function M.check(connection) return nil end local ok, spec = pcall(vim.json.decode, response.body) - if not ok or type(spec.paths) ~= 'table' then + if not ok then + log.warn('contract check: could not decode /openapi.json: %s', tostring(spec)) + return nil + end + if type(spec.paths) ~= 'table' then + log.warn('contract check: /openapi.json has no paths table') return nil end @@ -70,7 +75,8 @@ function M.check(connection) end return missing end) - :catch(function() + :catch(function(err) + log.warn('contract check skipped: %s', tostring(err)) return nil end) end diff --git a/lua/opencode/protocols/observation.lua b/lua/opencode/protocols/observation.lua index 7e2ecb2d..ceb448e4 100644 --- a/lua/opencode/protocols/observation.lua +++ b/lua/opencode/protocols/observation.lua @@ -1,3 +1,5 @@ +local log = require('opencode.log') + local M = {} local resource_names = { @@ -452,6 +454,7 @@ local function stream_failure(connection, owner, reason) close_stream(connection) local message = type(reason) == 'table' and tostring(reason.message or reason.code or 'event stream disconnected') or tostring(reason or 'event stream disconnected') + log.warn('event stream failed (%s): %s', owner.runtime.name, message) for _, observation in pairs(connection.observations) do if observation._runtime.on_stream_error then observation._runtime.on_stream_error(observation, message) @@ -472,7 +475,10 @@ local function decode_record(connection, owner) stream_failure(connection, owner, 'invalid ' .. owner.runtime.name .. ' event JSON') return end - owner.runtime.route_event(connection, event) + local routed, err = pcall(owner.runtime.route_event, connection, event) + if not routed then + log.error('event routing failed (%s): %s', owner.runtime.name, tostring(err)) + end end local function consume_stream_chunk(connection, owner, chunk) @@ -599,6 +605,13 @@ function Observation:_start_resource(resource) if owns_request() then self._loading[resource] = nil self._state.sync[resource] = M.sync_error('operation', err) + log.warn( + 'observation %s load failed for %s (%s): %s', + self._session_id, + resource, + self._runtime.name, + tostring(err) + ) self:_notify(resource) end end diff --git a/lua/opencode/state/session_tabs.lua b/lua/opencode/state/session_tabs.lua index 6c27798f..e5b6c11e 100644 --- a/lua/opencode/state/session_tabs.lua +++ b/lua/opencode/state/session_tabs.lua @@ -1,4 +1,5 @@ local store = require('opencode.state.store') +local log = require('opencode.log') local renderer_context = require('opencode.ui.renderer.ctx') ---@class OpencodeSessionTabRuntime @@ -307,7 +308,9 @@ function M.find_by_session_id(session_id) local render_state = runtime.renderer_context and runtime.renderer_context.render_state if render_state and render_state.get_task_part_by_child_session then local ok, task_part = pcall(render_state.get_task_part_by_child_session, render_state, session_id) - if ok and task_part then + if not ok then + log.warn('task part lookup failed for %s: %s', session_id, tostring(task_part)) + elseif task_part then return runtime end end @@ -610,6 +613,9 @@ function M.remove(runtime) if not runtime then return end + if runtime._draft_release then + runtime._draft_release() + end runtime.renderer_context:close() runtimes[runtime.id] = nil notify_change() diff --git a/lua/opencode/state/store.lua b/lua/opencode/state/store.lua index 09162fe0..0f484cf8 100644 --- a/lua/opencode/state/store.lua +++ b/lua/opencode/state/store.lua @@ -266,14 +266,17 @@ function M.emit(key, new_val, old_val) for _, cb in ipairs(_listeners[key]) do local ok, err = pcall(cb, key, new_val, old_val) if not ok then - vim.notify(err --[[@as string]]) + vim.notify(tostring(err)) end end end if _listeners['*'] then for _, cb in ipairs(_listeners['*']) do - pcall(cb, key, new_val, old_val) + local ok, err = pcall(cb, key, new_val, old_val) + if not ok then + vim.notify(tostring(err)) + end end end end) diff --git a/tests/unit/observation_logging_spec.lua b/tests/unit/observation_logging_spec.lua index a2831c1e..9804eea3 100644 --- a/tests/unit/observation_logging_spec.lua +++ b/tests/unit/observation_logging_spec.lua @@ -149,6 +149,22 @@ describe('observation logging', function() assert.equals(0, count_occurrences(read_log(), 'unhandled session event type: session%.foreign%.only')) end) + it('logs the session id when a resource snapshot request fails', function() + local observed = v1_observation_for('ses-log-resource') + local original = observed._runtime.request_resource + observed._runtime.request_resource = function() + error('snapshot unavailable') + end + + observed:watch({ 'session' }, function() end) + + observed._runtime.request_resource = original + assert.matches( + '%[WARN%].*observation ses%-log%-resource load failed for session %([^%)]+%): .*snapshot unavailable', + read_log() + ) + end) + it('does not log unknown events outside the session. prefix', function() local observed = v2_observation_for('ses-log-prefix') diff --git a/tests/unit/protocol_v2_contract_spec.lua b/tests/unit/protocol_v2_contract_spec.lua index 557beb59..326fa322 100644 --- a/tests/unit/protocol_v2_contract_spec.lua +++ b/tests/unit/protocol_v2_contract_spec.lua @@ -69,14 +69,18 @@ describe('V2 operations contract', function() vim.notify:revert() end) - it('skips the check quietly when the server does not answer with an openapi document', function() + it('logs the reason when the server does not answer with an openapi document', function() stub(transport, 'request').invokes(function() return Promise.new():reject({ kind = 'transport', cause = 'refused' }) end) - local warned = stub(log, 'warn').invokes(function() end) + local seen + local warned = stub(log, 'warn').invokes(function(_, err) + seen = err + end) assert.is_nil(contract_check.check({ protocol = 'v2', version = '2.0.14' }):wait()) - assert.stub(warned).was_not_called() + assert.stub(warned).was_called(1) + assert.is_truthy(seen:match('^table: ')) transport.request:revert() log.warn:revert() From 2ffeb41c43cd74032aabd5eb08a6ae5b1638a695 Mon Sep 17 00:00:00 2001 From: jensenojs Date: Tue, 20 Oct 2026 14:10:57 +0800 Subject: [PATCH 04/11] fix(v2): reject message ids that contain the synthetic id separator The v2 service sends text, reasoning and tool parts without a stable part id, so the plugin synthesizes one as owner ":" kind ":" key and only ever compares it. A colon inside the owner lets two different coordinates produce the same string, which renders two parts as one. Ids without a colon is current server behaviour, but that is an external contract; check it where the id is synthesized instead of assuming it. --- lua/opencode/protocols/v2/normalize.lua | 4 ++++ tests/unit/protocol_v2_observation_spec.lua | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/lua/opencode/protocols/v2/normalize.lua b/lua/opencode/protocols/v2/normalize.lua index 0234a410..2792da01 100644 --- a/lua/opencode/protocols/v2/normalize.lua +++ b/lua/opencode/protocols/v2/normalize.lua @@ -338,6 +338,10 @@ end ---@param key string|integer ---@return string local function synthetic_id(owner_id, kind, key) + -- ':' is the separator: a colon inside owner_id would let two coordinates collide. + if owner_id:find(':', 1, true) then + error('V2 observation: message id must not contain ":": ' .. owner_id) + end return owner_id .. ':' .. kind .. ':' .. tostring(key) end diff --git a/tests/unit/protocol_v2_observation_spec.lua b/tests/unit/protocol_v2_observation_spec.lua index e12cdfc2..96a4c104 100644 --- a/tests/unit/protocol_v2_observation_spec.lua +++ b/tests/unit/protocol_v2_observation_spec.lua @@ -113,6 +113,17 @@ describe('V2 protocol Observation interpretation', function() assert.equals(99, entry.cost) end) + it('rejects a message id with a colon, which would make synthesized part ids ambiguous', function() + local observed = observation('ses-target') + observation_module.ingest_snapshot(observed, { assistant('msg-assistant') }) + + local ok, err = pcall(observation_module.ingest_snapshot, observed, { assistant('msg:collide') }) + + assert.is_false(ok) + assert.matches('message id must not contain', tostring(err)) + assert.same({ 'msg-assistant' }, observed:read().entry_order) + end) + it('projects newest-first native snapshots into chronological frozen facts', function() local observed = observation('ses-target') observation_module.ingest_snapshot(observed, { From 8d2947653f7b2aaa393ac0150dda6cd6636e188f Mon Sep 17 00:00:00 2001 From: jensenojs Date: Tue, 20 Oct 2026 14:10:57 +0800 Subject: [PATCH 05/11] test: widen the empty multi-select submit wait The empty multi-select answer needs a second Enter and the test waited 200ms for it. That is enough locally and not on the macOS runners. --- tests/unit/question_window_spec.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/question_window_spec.lua b/tests/unit/question_window_spec.lua index 2518ac59..ba80933c 100644 --- a/tests/unit/question_window_spec.lua +++ b/tests/unit/question_window_spec.lua @@ -253,7 +253,7 @@ describe('question_window', function() question_window._dialog:select() - assert.is_true(vim.wait(200, function() + assert.is_true(vim.wait(2000, function() return #replies == 1 end)) assert.are.same({ choices = {} }, replies[1].answers) From 42beb0477a5475f8794669636be78811f139faf9 Mon Sep 17 00:00:00 2001 From: jensenojs Date: Tue, 20 Oct 2026 14:30:33 +0800 Subject: [PATCH 06/11] style: drop two comments that restate the code --- lua/opencode/protocols/v2/normalize.lua | 1 - lua/opencode/services/session_tab_persistence.lua | 1 - 2 files changed, 2 deletions(-) diff --git a/lua/opencode/protocols/v2/normalize.lua b/lua/opencode/protocols/v2/normalize.lua index 2792da01..a9d116f3 100644 --- a/lua/opencode/protocols/v2/normalize.lua +++ b/lua/opencode/protocols/v2/normalize.lua @@ -338,7 +338,6 @@ end ---@param key string|integer ---@return string local function synthetic_id(owner_id, kind, key) - -- ':' is the separator: a colon inside owner_id would let two coordinates collide. if owner_id:find(':', 1, true) then error('V2 observation: message id must not contain ":": ' .. owner_id) end diff --git a/lua/opencode/services/session_tab_persistence.lua b/lua/opencode/services/session_tab_persistence.lua index 011d00e9..7c410fa8 100644 --- a/lua/opencode/services/session_tab_persistence.lua +++ b/lua/opencode/services/session_tab_persistence.lua @@ -289,7 +289,6 @@ local restore_snapshot = Promise.async(function(snapshot, current, cwd, backgrou end -- Background restores keep the current tab active for the explicit new session. if #restored > 0 and not background then - -- Replaying the snapshot must not rewrite a file another instance may have changed. replaying_restore = true if save_timer then save_timer:stop() From 51c6491f04a313a43d2730abc8013ad06d5b306c Mon Sep 17 00:00:00 2001 From: jensenojs Date: Tue, 20 Oct 2026 14:36:25 +0800 Subject: [PATCH 07/11] fix(session): resolve the agent before creating a detached session --- lua/opencode/services/session_runtime.lua | 3 +- tests/unit/services_session_runtime_spec.lua | 31 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/lua/opencode/services/session_runtime.lua b/lua/opencode/services/session_runtime.lua index 1d6d6270..cff6d544 100644 --- a/lua/opencode/services/session_runtime.lua +++ b/lua/opencode/services/session_runtime.lua @@ -525,8 +525,9 @@ end) ---@return Promise M.create_detached_session = Promise.async(function(title_or_opts) local connection = server_job.ensure_server():await() + local selected = agent_model.resolve_session_selection():await() local location = current_location() - local session = create_session(connection, location, title_or_opts):await() + local session = create_session(connection, location, title_or_opts, selected):await() if not session then error('Failed to create detached session') end diff --git a/tests/unit/services_session_runtime_spec.lua b/tests/unit/services_session_runtime_spec.lua index 3ad06557..1e583441 100644 --- a/tests/unit/services_session_runtime_spec.lua +++ b/tests/unit/services_session_runtime_spec.lua @@ -478,13 +478,44 @@ describe('opencode.services.session_runtime', function() describe('detached session creation', function() local ensure_server + local resolve_selection local server_job = require('opencode.server_job') + before_each(function() + resolve_selection = stub(agent_model, 'resolve_session_selection').returns( + Promise.new():resolve({ mode = 'custom-agent', model = 'provider/model' }) + ) + end) + after_each(function() if ensure_server then ensure_server:revert() ensure_server = nil end + if resolve_selection then + resolve_selection:revert() + resolve_selection = nil + end + end) + + it('creates the session with the resolved agent selection', function() + local received + local connection = { + operations = { + create_session = function(_, _, _, _, _, selected) + received = selected + return Promise.new():resolve({ id = 'detached-id' }) + end, + }, + observe = function() + return {} + end, + } + ensure_server = stub(server_job, 'ensure_server').returns(Promise.new():resolve(connection)) + + session_runtime.create_detached_session():wait() + + assert.same({ mode = 'custom-agent', model = 'provider/model' }, received) end) for _, case in ipairs({ From 69eac43c51c7118e40127c42a64afea574f0249a Mon Sep 17 00:00:00 2001 From: jensenojs Date: Tue, 20 Oct 2026 14:50:07 +0800 Subject: [PATCH 08/11] fix(session): report why a connection closed and which session it stranded --- lua/opencode/opencode_server.lua | 16 +++++++++------- lua/opencode/protocols/observation.lua | 8 +++++--- lua/opencode/protocols/v1/observation.lua | 9 +++++---- lua/opencode/protocols/v2/observation.lua | 9 +++++---- lua/opencode/server_job.lua | 2 +- lua/opencode/services/messaging.lua | 5 ++++- 6 files changed, 29 insertions(+), 20 deletions(-) diff --git a/lua/opencode/opencode_server.lua b/lua/opencode/opencode_server.lua index 9d681c1f..1ed6154b 100644 --- a/lua/opencode/opencode_server.lua +++ b/lua/opencode/opencode_server.lua @@ -22,7 +22,7 @@ local protocol_connection = require('opencode.protocols.connection') ---@field private _stream? {shutdown: fun(self: table)} ---@field private _requests table ---@field private _observe? fun(connection: OpencodeServer, ref: {id: string, location?: OpencodeLocation}): OpencodeObservation ----@field private _close_observations? fun(connection: OpencodeServer) +---@field private _close_observations? fun(connection: OpencodeServer, reason: string) local OpencodeServer = {} OpencodeServer.__index = OpencodeServer @@ -38,7 +38,7 @@ local function ensure_vim_leave_autocmd() callback = function() local state = require('opencode.state') if state.opencode_server then - state.opencode_server:close() + state.opencode_server:close('vim exit') end end, }) @@ -217,8 +217,9 @@ function OpencodeServer:check_health() return protocol_connection.check_health(self) end +---@param reason string Why the connection is going away; stranded prompts report it ---@return Promise -function OpencodeServer:close() +function OpencodeServer:close(reason) if self.shutdown_promise:is_resolved() then return self.shutdown_promise end @@ -228,7 +229,7 @@ function OpencodeServer:close() local close_observations = self._close_observations self._close_observations = nil if close_observations then - close_observations(self) + close_observations(self, reason or 'unspecified') end local requests = self._requests self._requests = {} @@ -258,9 +259,10 @@ function OpencodeServer:close() return self.shutdown_promise end +---@param reason? string ---@return Promise -function OpencodeServer:shutdown() - return self:close() +function OpencodeServer:shutdown(reason) + return self:close(reason) end --- @class OpencodeServerSpawnOpts @@ -352,7 +354,7 @@ function OpencodeServer:spawn(opts) self.job = nil self.handle = nil safe_call(opts.on_exit, exit_opts) - self:close() + self:close('server process exited') end) self.handle = self.job and self.job.pid diff --git a/lua/opencode/protocols/observation.lua b/lua/opencode/protocols/observation.lua index ceb448e4..d9c901a6 100644 --- a/lua/opencode/protocols/observation.lua +++ b/lua/opencode/protocols/observation.lua @@ -58,7 +58,7 @@ local resource_names = { ---@field on_release_resource? fun(observation: OpencodeObservation, resource: OpencodeObservedResource) ---@field on_unused? fun(observation: OpencodeObservation) ---@field on_stream_error? fun(observation: OpencodeObservation, message: string) ----@field on_close? fun(observation: OpencodeObservation) +---@field on_close? fun(observation: OpencodeObservation, reason: string) ---@class OpencodeMessageSelection ---@field mode? string @@ -740,11 +740,13 @@ function M.attach(connection, session, state, runtime) }, Observation) end -function M.close(connection) +---@param connection table +---@param reason string Why the connection is closing; reaches every admission it strands +function M.close(connection, reason) close_stream(connection) for _, observation in pairs(connection.observations) do if observation._runtime.on_close then - observation._runtime.on_close(observation) + observation._runtime.on_close(observation, reason) end end connection.observations = {} diff --git a/lua/opencode/protocols/v1/observation.lua b/lua/opencode/protocols/v1/observation.lua index 558c604a..095ce519 100644 --- a/lua/opencode/protocols/v1/observation.lua +++ b/lua/opencode/protocols/v1/observation.lua @@ -981,8 +981,9 @@ local function track_submission(observation, result) end ---@param connection table -function M.close(connection) - lifecycle.close(connection) +---@param reason string +function M.close(connection, reason) + lifecycle.close(connection, reason) end local function clear_unresolved_mentions(observation) @@ -1023,8 +1024,8 @@ function M.new(connection, ref) on_stream_error = function(current, message) fail_submissions(current, 'V1 reply completion is unknown: ' .. message) end, - on_close = function(current) - fail_submissions(current, 'connection closed') + on_close = function(current, reason) + fail_submissions(current, 'connection closed (' .. tostring(reason) .. ')') clear_unresolved_mentions(current) end, }) diff --git a/lua/opencode/protocols/v2/observation.lua b/lua/opencode/protocols/v2/observation.lua index b879dddc..28303df0 100644 --- a/lua/opencode/protocols/v2/observation.lua +++ b/lua/opencode/protocols/v2/observation.lua @@ -53,9 +53,9 @@ function M.new(connection, ref) end, on_stream_error = actions.invalidate_submissions, ---@param current OpencodeObservation - on_close = function(current) + on_close = function(current, reason) ---@cast current OpencodeV2Observation - actions.invalidate_submissions(current, 'connection closed') + actions.invalidate_submissions(current, 'connection closed (' .. tostring(reason) .. ')') end, }) ---@cast observation OpencodeV2Observation @@ -68,8 +68,9 @@ function M.new(connection, ref) end ---@param connection OpencodeV2Connection -function M.close(connection) - lifecycle.close(connection) +---@param reason string +function M.close(connection, reason) + lifecycle.close(connection, reason) end return M diff --git a/lua/opencode/server_job.lua b/lua/opencode/server_job.lua index ee575d6e..59ef6df3 100644 --- a/lua/opencode/server_job.lua +++ b/lua/opencode/server_job.lua @@ -508,7 +508,7 @@ function M.spawn_local_server(promise, port, hostname) end) :catch(function(err) log.notify(' Failed to start opencode server' .. vim.inspect(err), vim.log.levels.ERROR) - server:shutdown() + server:shutdown('startup probe failed') promise:reject(err) end) end, diff --git a/lua/opencode/services/messaging.lua b/lua/opencode/services/messaging.lua index c52c6ad5..2924e255 100644 --- a/lua/opencode/services/messaging.lua +++ b/lua/opencode/services/messaging.lua @@ -165,7 +165,10 @@ local function submit_message(observation, prompt, tab_id, session_id, prepared, optimistic.finish(observation, pending, not ok and (admitted and 'unknown' or 'failed') or nil) if not ok then local prefix = admitted and 'Prompt result is unknown: ' or 'Error sending message to session: ' - log.notify(prefix .. tostring(result), admitted and vim.log.levels.WARN or vim.log.levels.ERROR) + log.notify( + string.format('%s%s (session: %s)', prefix, tostring(result), session_id), + admitted and vim.log.levels.WARN or vim.log.levels.ERROR + ) return end return result From 18dc3564ee4116eacaf41fed3a08e8027fd079ca Mon Sep 17 00:00:00 2001 From: jensenojs Date: Tue, 20 Oct 2026 14:55:26 +0800 Subject: [PATCH 09/11] test: give the v2 resource flush helper time to finish on slow runners --- tests/unit/protocol_v2_observation_runtime_spec.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/protocol_v2_observation_runtime_spec.lua b/tests/unit/protocol_v2_observation_runtime_spec.lua index 8707d6ba..370a1213 100644 --- a/tests/unit/protocol_v2_observation_runtime_spec.lua +++ b/tests/unit/protocol_v2_observation_runtime_spec.lua @@ -90,7 +90,7 @@ local function event(session_id, kind, data, created) end local function flush(predicate) - assert.is_true(vim.wait(500, predicate or function() + assert.is_true(vim.wait(2000, predicate or function() return true end, 5)) end From eae4f81a6f03ec5c3568b278e3aeb9e3d4393a92 Mon Sep 17 00:00:00 2001 From: jensenojs Date: Tue, 20 Oct 2026 21:13:33 +0800 Subject: [PATCH 10/11] fix(session): leave the new-session tab empty until the first prompt --- lua/opencode/services/session_runtime.lua | 2 -- tests/unit/services_session_runtime_spec.lua | 12 ++++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/lua/opencode/services/session_runtime.lua b/lua/opencode/services/session_runtime.lua index cff6d544..22a11b7a 100644 --- a/lua/opencode/services/session_runtime.lua +++ b/lua/opencode/services/session_runtime.lua @@ -447,8 +447,6 @@ M.open = Promise.async(function(opts) if opts.new_session then state.session.clear_active() 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 diff --git a/tests/unit/services_session_runtime_spec.lua b/tests/unit/services_session_runtime_spec.lua index 1e583441..cad8ba48 100644 --- a/tests/unit/services_session_runtime_spec.lua +++ b/tests/unit/services_session_runtime_spec.lua @@ -332,11 +332,11 @@ describe('opencode.services.session_runtime', function() vim.fn.getcwd = original_getcwd end) - it('handles new session properly', function() + it('leaves the new-session tab empty until the first prompt', function() state.ui.set_windows(nil) state.session.set_active({ id = 'old-session' }) session_runtime.open({ new_session = true, focus = 'input' }):wait() - assert.truthy(state.active_session) + assert.is_nil(state.active_session) end) it('clears old attachments before loading editor context for a new session', function() @@ -403,9 +403,9 @@ describe('opencode.services.session_runtime', function() state.ui.set_windows(nil) store.set('is_opening', false) - local original_create_new_session = session_runtime.create_new_session - session_runtime.create_new_session = function() - error('Test error in create_new_session') + local original_ensure_current_mode = agent_model.ensure_current_mode + agent_model.ensure_current_mode = function() + error('Test error in ensure_current_mode') end local notify_stub = stub(vim, 'notify') @@ -420,7 +420,7 @@ describe('opencode.services.session_runtime', function() assert.is_false(state.is_opening) assert.stub(notify_stub).was_called() - session_runtime.create_new_session = original_create_new_session + agent_model.ensure_current_mode = original_ensure_current_mode notify_stub:revert() end) end) From fbf6a2362cd1e8ea9cfa1566eb25231fae1b5b94 Mon Sep 17 00:00:00 2001 From: jensenojs Date: Wed, 21 Oct 2026 01:10:57 +0800 Subject: [PATCH 11/11] fix(session): bind a session to one tab and leave empty tabs empty `N` and switching to a tab without a session both went through `M.open`, which resumed the last workspace session. The resume ran through `set_active`, which bound that session to whichever tab was active, so the tab that already owned it ended up duplicated. Sessions are now owned by the tab: `runtime.active_session` is the source of truth, `store.active_session` only projects the active tab, and asking for a session another tab owns activates that tab instead of creating a second one. `verify_unique_session_tabs` reports a violation loudly at every point that mutates the registry. An empty tab stays empty: `M.open({ draft = true })` skips the resume branch, and the first prompt creates the session. --- lua/opencode/services/messaging.lua | 2 +- lua/opencode/services/session_runtime.lua | 15 ++++-- lua/opencode/state/session.lua | 20 +++++++ lua/opencode/state/session_tabs.lua | 56 ++++++++++++++++++++ lua/opencode/types.lua | 1 + tests/unit/renderer_context_spec.lua | 1 + tests/unit/services_session_runtime_spec.lua | 20 +++++++ tests/unit/session_observation_spec.lua | 30 +++-------- tests/unit/session_tab_persistence_spec.lua | 15 ++++++ tests/unit/session_tabs_spec.lua | 40 ++++++++++++++ 10 files changed, 172 insertions(+), 28 deletions(-) diff --git a/lua/opencode/services/messaging.lua b/lua/opencode/services/messaging.lua index 2924e255..1e483d01 100644 --- a/lua/opencode/services/messaging.lua +++ b/lua/opencode/services/messaging.lua @@ -250,7 +250,7 @@ local function materialize_draft_session(tab_id, token) if session_tabs.active_id() == tab_id then state.session.set_active(session) else - owner.active_session = session + session_tabs.set_session(owner, session) end return observation, snapshot, release end diff --git a/lua/opencode/services/session_runtime.lua b/lua/opencode/services/session_runtime.lua index 22a11b7a..517c29a9 100644 --- a/lua/opencode/services/session_runtime.lua +++ b/lua/opencode/services/session_runtime.lua @@ -449,7 +449,7 @@ M.open = Promise.async(function(opts) agent_model.ensure_current_mode():await() else agent_model.ensure_current_mode():await() - if not state.active_session then + if not state.active_session and not opts.draft then local resumed, recent = pcall(function() return last_workspace_session():await() end) @@ -655,16 +655,16 @@ M.fork_session = Promise.async(function(session, message_id) end) ---Mount an existing session in a new logical panel tab. ----@param selected_session OpencodeSession +---@param selected_session OpencodeSession|nil Nil mounts a tab with no session; the first prompt creates one. ---@param directory? string Explicit tab directory; omitted preserves existing behavior. ---@return Promise M.open_session_in_tab = Promise.async(function(selected_session, directory) - if not selected_session or not selected_session.id then + if selected_session and not selected_session.id then return nil end for _, runtime in ipairs(session_tabs.list()) do - if runtime.active_session and runtime.active_session.id == selected_session.id then + if selected_session and runtime.active_session and runtime.active_session.id == selected_session.id then if directory then runtime.bound_directory = directory runtime.bound_editor_cwd = vim.fn.getcwd() @@ -699,6 +699,7 @@ M.open_session_in_tab = Promise.async(function(selected_session, directory) focus = 'input', start_insert = true, new_session = false, + draft = selected_session == nil, open_action = 'create_fresh', }):await() @@ -788,11 +789,14 @@ M.open_session_in_tab_by_id = Promise.async(function(session_id) end) ---Open a new session in a logical tab inside the Opencode panel. ----@param title? string +---@param title? string Named tabs create the session up front; untitled ones stay empty until the first prompt. ---@return Promise M.open_session_tab = Promise.async(function(title) local runtime = session_tabs.current() local directory = runtime and runtime.bound_directory + if not title then + return M.open_session_in_tab(nil, directory):await() + end local new_session = M.create_new_session(title):await() if not new_session then return nil @@ -830,6 +834,7 @@ M.switch_session_tab = Promise.async(function(tab_id) focus = focus, new_session = false, open_action = 'restore_hidden', + draft = runtime.active_session == nil, }):await() return runtime.active_session diff --git a/lua/opencode/state/session.lua b/lua/opencode/state/session.lua index 93926a09..a9da432b 100644 --- a/lua/opencode/state/session.lua +++ b/lua/opencode/state/session.lua @@ -4,6 +4,23 @@ local session_tabs = require('opencode.state.session_tabs') ---@class OpencodeSessionStateMutations local M = {} +---The active tab is the session owner; the store only projects it. +---A session never migrates between tabs: activating a session someone else owns +---activates that owner instead. +---@param session OpencodeSession|nil +local function bind_active_tab(session) + local runtime = session_tabs.current() + if not runtime then + return + end + local owner = session and session.id and session_tabs.owner_of(session.id) + if owner and owner.id ~= runtime.id then + session_tabs.activate(owner) + return + end + session_tabs.set_session(runtime, session) +end + ---@param session OpencodeSession|nil function M.set_active(session) local ref @@ -27,6 +44,7 @@ function M.set_active(session) end end + bind_active_tab(ref) local result = store.batch(function() store.set('restore_points', {}) store.set('last_sent_context', nil) @@ -55,6 +73,7 @@ function M.update_active_metadata(session) return active end + bind_active_tab(updated) local result = store.set('active_session', updated) session_tabs.sync() return result @@ -79,6 +98,7 @@ function M.clear_active() end end + bind_active_tab(nil) local result = store.batch(function() store.set('restore_points', {}) store.set('last_sent_context', nil) diff --git a/lua/opencode/state/session_tabs.lua b/lua/opencode/state/session_tabs.lua index e5b6c11e..6d91c4e5 100644 --- a/lua/opencode/state/session_tabs.lua +++ b/lua/opencode/state/session_tabs.lua @@ -217,11 +217,40 @@ local function capture_runtime(id) return nil end + local session = runtime.active_session copy_from_store(runtime) + runtime.active_session = session runtime.id = id return runtime end +---@param session_id string +---@return OpencodeSessionTabRuntime|nil +function M.owner_of(session_id) + for _, runtime in pairs(runtimes) do + local active = runtime.active_session + if active and active.id == session_id then + return runtime + end + end + return nil +end + +---@param context string Operation that just changed the tab registry +local function verify_unique_session_tabs(context) + local seen = {} + for _, runtime in pairs(runtimes) do + local session_id = runtime.active_session and runtime.active_session.id + if session_id then + local owner = rawget(seen, session_id) + if owner then + error(('session %s is bound to both tab %s and tab %s after %s'):format(session_id, owner, runtime.id, context)) + end + seen[session_id] = runtime.id + end + end +end + ---@param group? string Omitted includes every group ---@return OpencodeSessionTabRuntime[] local function list(group) @@ -553,11 +582,19 @@ function M.ensure_current() return runtimes[id] end + local session = store.get('active_session') + local existing = session and session.id and M.owner_of(session.id) + if existing then + M.activate(existing) + return existing + end + id = new_id() local runtime = runtime_from_current(id, false) runtimes[id] = runtime renderer_context.select(runtime.renderer_context) store.set('active_session_tab', id) + verify_unique_session_tabs('ensure_current') return runtime end @@ -585,12 +622,21 @@ function M.activate(runtime) capture_runtime(runtime.id) end + verify_unique_session_tabs('activate') return true end ---@param session OpencodeSession|nil ---@return OpencodeSessionTabRuntime function M.create(session) + if session and session.id then + local existing = M.owner_of(session.id) + if existing then + log.warn('session %s already has tab %s; reusing it instead of creating a second one', session.id, existing.id) + return existing + end + end + local runtime = runtime_from_current(new_id(), false) runtime.active_session = normalize_session(session) runtime.messages = nil @@ -605,9 +651,17 @@ function M.create(session) runtime.tokens_count = 0 runtimes[runtime.id] = runtime notify_change() + verify_unique_session_tabs('create') return runtime end +---@param runtime OpencodeSessionTabRuntime +---@param session OpencodeSession|nil +function M.set_session(runtime, session) + runtime.active_session = normalize_session(session) + verify_unique_session_tabs('set_session') +end + ---@param runtime OpencodeSessionTabRuntime function M.remove(runtime) if not runtime then @@ -619,6 +673,7 @@ function M.remove(runtime) runtime.renderer_context:close() runtimes[runtime.id] = nil notify_change() + verify_unique_session_tabs('remove') if store.get('active_session_tab') == runtime.id then renderer_context.select() store.set('active_session_tab', nil) @@ -649,6 +704,7 @@ function M.setup() runtimes[runtime.id] = runtime renderer_context.select(runtime.renderer_context) store.set('active_session_tab', runtime.id) + verify_unique_session_tabs('setup') end return M diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index e0d05faa..1d13cd6b 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -556,6 +556,7 @@ ---@field focus? 'input' | 'output' ---@field start_insert? boolean ---@field new_session? boolean +---@field draft? boolean Open without resuming a session; the tab stays empty until the first prompt ---@field open_action? 'reuse_visible'|'restore_hidden'|'create_fresh' ---@class SendMessageOpts diff --git a/tests/unit/renderer_context_spec.lua b/tests/unit/renderer_context_spec.lua index 06af132b..f4ba82fd 100644 --- a/tests/unit/renderer_context_spec.lua +++ b/tests/unit/renderer_context_spec.lua @@ -223,6 +223,7 @@ describe('renderer context ownership', function() end, }) state.store.set_raw('active_session', { id = 'one' }) + tabs.set_session(first, { id = 'one' }) renderer.on_session_changed() local a, b = first.renderer_context, second.renderer_context local original_session = a.render_session diff --git a/tests/unit/services_session_runtime_spec.lua b/tests/unit/services_session_runtime_spec.lua index cad8ba48..4f1f62a9 100644 --- a/tests/unit/services_session_runtime_spec.lua +++ b/tests/unit/services_session_runtime_spec.lua @@ -31,6 +31,9 @@ describe('opencode.services.session_runtime', function() before_each(function() original = support.snapshot_state() + -- Tabs are not part of the snapshot; leftovers from earlier specs would + -- otherwise be bound to the sessions these tests activate. + state.session_tabs.reset() -- Keep open() away from snapshots in the real stdpath('state'). original_restore_tabs_on_startup = config.ui.restore_tabs_on_startup config.ui.restore_tabs_on_startup = false @@ -339,6 +342,23 @@ describe('opencode.services.session_runtime', function() assert.is_nil(state.active_session) end) + it('leaves a new session tab empty instead of resuming the last session', function() + state.ui.set_windows(nil) + state.session.set_active({ id = 'old-session' }) + state.opencode_server.operations.list_sessions_project = function() + return Promise.new():resolve({ + { id = 'recent-session', title = 'recent', time = { updated = 5 } }, + }) + end + local create = stub(session_runtime, 'create_new_session').returns(Promise.new():resolve({ id = 'eager' })) + + session_runtime.open_session_tab():wait() + + assert.stub(create).was_not_called() + assert.is_nil(state.active_session) + create:revert() + end) + it('clears old attachments before loading editor context for a new session', function() local context = require('opencode.context') local calls = {} diff --git a/tests/unit/session_observation_spec.lua b/tests/unit/session_observation_spec.lua index dfdca277..71bab73f 100644 --- a/tests/unit/session_observation_spec.lua +++ b/tests/unit/session_observation_spec.lua @@ -261,30 +261,16 @@ describe('active session observation', function() assert.equals('one', state.active_session.title) end) - it('reacquires an observation released while rebinding the same session to another tab', function() + it('keeps the observation bound when the same session is asked for again', function() local source = observation('one') - local watch = source.watch - function source:watch(resources, callback) - local release = watch(self, resources, callback) - return function() - release() - connection.observations.one = nil - end - end - function connection:observe(ref) - return self.observations[ref.id] or observation(ref.id) - end activate('one') - local second_tab = tabs.create({ id = 'one' }) - tabs.activate(second_tab) + local again = tabs.create({ id = 'one' }) + tabs.activate(again) settle() - assert.equals(1, source.releases) - local current = connection.observations.one - assert.is_not_nil(current) - assert.is_not_equal(source, current) - assert.equals(1, current.subscriptions) - current.facts.session.title = 'Current title' - current.changed('session') - assert.equals('Current title', second_tab.active_session.title) + assert.equals(0, source.releases) + assert.equals(1, source.subscriptions) + source.facts.session.title = 'Current title' + source.changed('session') + assert.equals('Current title', again.active_session.title) end) end) diff --git a/tests/unit/session_tab_persistence_spec.lua b/tests/unit/session_tab_persistence_spec.lua index 99015c84..ca5bc16a 100644 --- a/tests/unit/session_tab_persistence_spec.lua +++ b/tests/unit/session_tab_persistence_spec.lua @@ -115,6 +115,21 @@ describe('session tab persistence', function() assert.is_nil(state.windows) end) + it('collapses a snapshot that lists the same session twice', function() + save_tabs() + local path = snapshot_path() + local saved = vim.json.decode(table.concat(vim.fn.readfile(path), '\n')) + table.insert(saved.tabs, vim.deepcopy(saved.tabs[1])) + saved.active_index = #saved.tabs + vim.fn.writefile({ vim.json.encode(saved) }, path) + restart() + restore() + local restored = tabs.list() + assert.equals(2, #restored) + assert.equals('first', restored[1].active_session.id) + assert.equals('second', restored[2].active_session.id) + end) + it('restores on first open only, not on setup or subsequent calls', function() save_tabs() restart() diff --git a/tests/unit/session_tabs_spec.lua b/tests/unit/session_tabs_spec.lua index 4c03f817..642da0c7 100644 --- a/tests/unit/session_tabs_spec.lua +++ b/tests/unit/session_tabs_spec.lua @@ -17,6 +17,14 @@ describe('opencode session panel tabs', function() vim.wait(50) require('opencode.ui.autocmds').setup_subscriptions(false) session_tabs.reset() + -- Restoring only the captured keys leaves anything a test added behind; + -- a leaked active_session makes the next test's tab inherit it. + local added = vim.tbl_filter(function(key) + return original_state[key] == nil + end, vim.tbl_keys(store.state())) + for _, key in ipairs(added) do + store.set(key, nil) + end for key, value in pairs(original_state) do store.set(key, value) end @@ -84,6 +92,26 @@ describe('opencode session panel tabs', function() assert.equals('old input', state.input_content[1]) end) + it('reuses the tab that already holds the session instead of creating a second one', function() + local first = session_tabs.create({ id = 'same-session' }) + + local second = session_tabs.create({ id = 'same-session' }) + + assert.equals(first.id, second.id) + assert.equals(1, #session_tabs.list()) + end) + + it('does not create a second tab when the active tab pointer is stale', function() + local first = session_tabs.create({ id = 'session-one' }) + session_tabs.activate(first) + store.set('active_session_tab', 'gone') + + local current = session_tabs.ensure_current() + + assert.equals(first.id, current.id) + assert.equals(1, #session_tabs.list()) + end) + it('normalizes V1 session directories before activating a tab', function() local observed_ref state.jobs.set_server({ @@ -264,4 +292,16 @@ describe('opencode session panel tabs', function() sync_model_stub:revert() render_stub:revert() end) + + it('activates the tab that owns a session instead of binding it to the current tab', function() + local first = session_tabs.ensure_current() + local second = session_tabs.create({ id = 'session-two' }) + session_tabs.activate(first) + + state.session.set_active({ id = 'session-two' }) + + assert.equals(second.id, session_tabs.active_id()) + assert.is_nil(first.active_session) + assert.equals('session-two', state.active_session.id) + end) end)