Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions lua/opencode/services/agent_model.lua
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
return
end

local config_file = require('opencode.config_file')

Check notice on line 72 in lua/opencode/services/agent_model.lua

View workflow job for this annotation

GitHub Actions / typecheck

The same file is required multiple times.

Check notice on line 72 in lua/opencode/services/agent_model.lua

View workflow job for this annotation

GitHub Actions / typecheck

Redefined local variable `config_file`
config_file.get_opencode_providers():await()
local model_info = config_file.get_model_info(provider, model)

Expand Down Expand Up @@ -249,4 +249,40 @@
return state.current_model
end)

---Apply a server-side model selection ({id or modelID, providerID, variant?})
---to local state; same-value calls are no-ops.
---@param model? table
function M.apply_server_selection(model)
local model_id = model and (model.id or model.modelID)
if not (model and model_id and model.providerID) then
return
end
local model_str = model.providerID .. '/' .. model_id
if state.current_model ~= model_str then
state.model.set_model(model_str)
end
if model.variant ~= nil and state.current_variant ~= model.variant then
state.model.set_variant(model.variant)
end
end

---Pull the authoritative session model from the server so state converges on
---panel open; live changes arrive via session.model.selected events.
---@return Promise|nil nil when there is no ready connection or active session
function M.sync_from_server()
local connection = state.opencode_server
local session = state.active_session
if not (connection and session and session.id and connection:is_ready()) then
return nil
end
return connection.operations
.get_session(connection, session.id, session.location, util.apply_path_map, util.apply_reverse_path_map)
:and_then(function(fact)
M.apply_server_selection(fact and fact.model)
end)
:catch(function(err)
log.debug('Failed to sync session model from server', { error = err })
end)
end

return M
25 changes: 25 additions & 0 deletions lua/opencode/services/session_runtime.lua
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@
local tab_id = state.active_session_tab
local function is_current()
return active_binding == binding
and state.opencode_server == connection

Check notice on line 49 in lua/opencode/services/session_runtime.lua

View workflow job for this annotation

GitHub Actions / typecheck

Prefer use local alias variable 'connection'
and connection:is_ready()
and state.active_session_tab == tab_id

Check notice on line 51 in lua/opencode/services/session_runtime.lua

View workflow job for this annotation

GitHub Actions / typecheck

Prefer use local alias variable 'tab_id'
and state.active_session ~= nil
and state.active_session.id == session_id
end
Expand Down Expand Up @@ -122,6 +122,25 @@
})
end

local function setup_model_sync_events(enable)
vim.api.nvim_create_augroup('opencode_model_sync', { clear = true })
if not enable then
return
end
vim.api.nvim_create_autocmd('User', {
group = 'opencode_model_sync',
pattern = 'OpencodeEvent:session.model.selected',
callback = function(args)
local props = args.data and args.data.event and args.data.event.properties
local session = state.active_session
if not (props and props.sessionID and session and props.sessionID == session.id) then
return
end
agent_model.apply_server_selection(props.model)
end,
})
end

---Keep active-session metadata and model selection current independently of rendering.
---Disabling releases the observation; enabling also adopts already-loaded facts.
---@param subscribe? boolean Defaults to true
Expand All @@ -139,6 +158,7 @@
else
observe_active_session()
end
setup_model_sync_events(subscribe ~= false)
end

local function current_location()
Expand Down Expand Up @@ -398,6 +418,11 @@
vim.notify('Error opening panel: ' .. tostring(err), vim.log.levels.ERROR)
return Promise.new():reject(err)
end

-- Re-sync model/variant from the server: the user may have switched them
-- in the TUI while this client was unfocused (no SSE broadcast on switch).
agent_model.sync_from_server()

return Promise.new():resolve('ok')
end)

Expand Down
3 changes: 3 additions & 0 deletions tests/unit/persist_state_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ describe('persist_state', function()
create_session = function()
return Promise.new():resolve({ id = 'persist-test-session', title = 'Persist test', time = { updated = 1 } })
end,
get_session = function(_, id)
return Promise.new():resolve({ id = id, title = id, time = { updated = 1 } })
end,
list_primary_agents = function()
return Promise.new():resolve({ 'build' })
end,
Expand Down
39 changes: 39 additions & 0 deletions tests/unit/services_agent_model_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -275,4 +275,43 @@ describe('opencode.services.agent_model', function()
config_file.get_opencode_agents:revert()
config_file.get_opencode_config:revert()
end)

describe('apply_server_selection', function()
it('applies a server model ref to state', function()
state.store.set('current_model', 'openai/gpt-4.1')
agent_model.apply_server_selection({ id = 'gemini-4-argon', providerID = 'opencode' })
assert.equal('opencode/gemini-4-argon', state.current_model)
end)

it('accepts the V1 modelID shape', function()
state.store.set('current_model', nil)
agent_model.apply_server_selection({ modelID = 'claude-3-opus', providerID = 'anthropic' })
assert.equal('anthropic/claude-3-opus', state.current_model)
end)

it('updates the variant only when the server provides one', function()
state.store.set('current_model', 'anthropic/claude-3-opus')
state.model.set_variant('high')
agent_model.apply_server_selection({ id = 'claude-3-opus', providerID = 'anthropic' })
assert.equal('high', state.current_variant)
agent_model.apply_server_selection({ id = 'claude-3-opus', providerID = 'anthropic', variant = 'low' })
assert.equal('low', state.current_variant)
end)

it('keeps the local selection when the model ref matches', function()
state.store.set('current_model', 'anthropic/claude-3-opus')
state.store.set('current_variant', 'high')
agent_model.apply_server_selection({ id = 'claude-3-opus', providerID = 'anthropic', variant = 'high' })
assert.equal('anthropic/claude-3-opus', state.current_model)
assert.equal('high', state.current_variant)
end)

it('ignores incomplete model refs', function()
state.store.set('current_model', 'anthropic/claude-3-opus')
agent_model.apply_server_selection(nil)
agent_model.apply_server_selection({ id = 'claude-3-opus' })
agent_model.apply_server_selection({ providerID = 'anthropic' })
assert.equal('anthropic/claude-3-opus', state.current_model)
end)
end)
end)
2 changes: 2 additions & 0 deletions tests/unit/session_tabs_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ describe('opencode session panel tabs', function()
stub(session_runtime, 'create_new_session').returns(Promise.new():resolve({ id = 'session-two', title = 'Two' }))
local ensure_server_stub = stub(server_job, 'ensure_server').returns(Promise.new():resolve(server))
local ensure_mode_stub = stub(agent_model, 'ensure_current_mode').returns(Promise.new():resolve(true))
local sync_model_stub = stub(agent_model, 'sync_from_server').returns(Promise.new():resolve(true))
local render_stub = stub(renderer, 'render_full_session').returns(Promise.new():resolve(nil))

state.session.set_active({ id = 'session-one', title = 'One' })
Expand Down Expand Up @@ -260,6 +261,7 @@ describe('opencode session panel tabs', function()
create_session_stub:revert()
ensure_server_stub:revert()
ensure_mode_stub:revert()
sync_model_stub:revert()
render_stub:revert()
end)
end)
Loading