From ea23d9eb8c554d38a4460d82e7d28a1d865fc7de Mon Sep 17 00:00:00 2001 From: jensenojs Date: Fri, 2 Oct 2026 15:03:45 +0800 Subject: [PATCH] feat(agent): follow server model switches via session.model.selected --- lua/opencode/services/agent_model.lua | 36 +++++++++++++++++++++ lua/opencode/services/session_runtime.lua | 25 +++++++++++++++ tests/unit/persist_state_spec.lua | 3 ++ tests/unit/services_agent_model_spec.lua | 39 +++++++++++++++++++++++ tests/unit/session_tabs_spec.lua | 2 ++ 5 files changed, 105 insertions(+) diff --git a/lua/opencode/services/agent_model.lua b/lua/opencode/services/agent_model.lua index 9018c9070..88cb903cf 100644 --- a/lua/opencode/services/agent_model.lua +++ b/lua/opencode/services/agent_model.lua @@ -249,4 +249,40 @@ M.initialize_current_model = Promise.async(function(opts) 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 diff --git a/lua/opencode/services/session_runtime.lua b/lua/opencode/services/session_runtime.lua index 9255841ae..3017a24d4 100644 --- a/lua/opencode/services/session_runtime.lua +++ b/lua/opencode/services/session_runtime.lua @@ -122,6 +122,25 @@ function M.setup_done_thinking_events(subscribe) }) 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 @@ -139,6 +158,7 @@ function M.setup_subscriptions(subscribe) else observe_active_session() end + setup_model_sync_events(subscribe ~= false) end local function current_location() @@ -398,6 +418,11 @@ M.open = Promise.async(function(opts) 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) diff --git a/tests/unit/persist_state_spec.lua b/tests/unit/persist_state_spec.lua index 3ba35a2a8..2a6365318 100644 --- a/tests/unit/persist_state_spec.lua +++ b/tests/unit/persist_state_spec.lua @@ -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, diff --git a/tests/unit/services_agent_model_spec.lua b/tests/unit/services_agent_model_spec.lua index b36737ac0..312b0f554 100644 --- a/tests/unit/services_agent_model_spec.lua +++ b/tests/unit/services_agent_model_spec.lua @@ -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) diff --git a/tests/unit/session_tabs_spec.lua b/tests/unit/session_tabs_spec.lua index da8bf859f..4c03f8175 100644 --- a/tests/unit/session_tabs_spec.lua +++ b/tests/unit/session_tabs_spec.lua @@ -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' }) @@ -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)