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/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.
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/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..d9c901a6 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 = {
@@ -56,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
@@ -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
@@ -727,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/normalize.lua b/lua/opencode/protocols/v2/normalize.lua
index 0234a410..a9d116f3 100644
--- a/lua/opencode/protocols/v2/normalize.lua
+++ b/lua/opencode/protocols/v2/normalize.lua
@@ -338,6 +338,9 @@ end
---@param key string|integer
---@return string
local function synthetic_id(owner_id, kind, key)
+ 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/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/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..1e483d01 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
@@ -135,31 +165,171 @@ 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
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
+ session_tabs.set_session(owner, 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 +337,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..517c29a9 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
@@ -447,17 +447,18 @@ 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
- 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())
+ if not state.active_session and not opts.draft then
+ 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
@@ -522,8 +523,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
@@ -653,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()
@@ -697,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()
@@ -786,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
@@ -828,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/services/session_tab_persistence.lua b/lua/opencode/services/session_tab_persistence.lua
index cbfdefce..7c410fa8 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,20 @@ 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_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 +320,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 +366,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 +382,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 +423,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 +485,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/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 6c27798f..6d91c4e5 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
@@ -216,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)
@@ -307,7 +337,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
@@ -550,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
@@ -582,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
@@ -602,17 +651,29 @@ 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
return
end
+ if runtime._draft_release then
+ runtime._draft_release()
+ end
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)
@@ -643,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/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/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/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()
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
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, {
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)
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_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..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
@@ -332,11 +335,28 @@ 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('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()
@@ -387,7 +407,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,17 +416,16 @@ 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()
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')
@@ -421,7 +440,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)
@@ -479,13 +498,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({
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 311f8b57..ca5bc16a 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()
@@ -114,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()
@@ -144,6 +160,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 +193,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 +282,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 +322,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 +393,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 +466,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 +604,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 +622,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 +633,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 +688,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 +703,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 +720,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)
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)