From ddb713937ffe2548e05b65d2f1ded13dc061429d Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 15 Oct 2026 10:07:12 -0400 Subject: [PATCH] fix: apply agent-specific defaults to new sessions Resolve effective V2 model defaults and pass the selected agent, model, and variant into session creation. Preserve explicit creation options and keep V1 requests unchanged. this should fix #532 --- lua/opencode/protocols/v1/types.lua | 2 +- lua/opencode/protocols/v2/operations.lua | 42 +++++++++--- lua/opencode/protocols/v2/types.lua | 6 +- lua/opencode/services/agent_model.lua | 16 ++++- lua/opencode/services/session_runtime.lua | 8 ++- lua/opencode/types.lua | 5 ++ tests/unit/protocol_v1_operations_spec.lua | 8 ++- tests/unit/protocol_v2_operations_spec.lua | 69 +++++++++++++++----- tests/unit/services_agent_model_spec.lua | 37 +++++++++++ tests/unit/services_session_runtime_spec.lua | 59 +++++++++++++++++ 10 files changed, 218 insertions(+), 34 deletions(-) diff --git a/lua/opencode/protocols/v1/types.lua b/lua/opencode/protocols/v1/types.lua index 298ce1f9..f06de879 100644 --- a/lua/opencode/protocols/v1/types.lua +++ b/lua/opencode/protocols/v1/types.lua @@ -14,7 +14,7 @@ ---@field list_sessions_project OpencodeV1LocationTableOperation ---@field list_session_status OpencodeV1LocationTableOperation ---@field list_sessions_global fun(connection: OpencodeV1Connection, reverse_path_map?: OpencodeV1PathMap): Promise ----@field create_session fun(connection: OpencodeV1Connection, location: OpencodeLocation, input?: table, path_map?: OpencodeV1PathMap, reverse_path_map?: OpencodeV1PathMap): Promise +---@field create_session fun(connection: OpencodeV1Connection, location: OpencodeLocation, input?: table, path_map?: OpencodeV1PathMap, reverse_path_map?: OpencodeV1PathMap, selected?: OpencodeSessionSelection): Promise
---@field delete_session fun(connection: OpencodeV1Connection, session_id: string, location: OpencodeLocation, path_map?: OpencodeV1PathMap): Promise ---@field rename_session fun(connection: OpencodeV1Connection, session_id: string, location: OpencodeLocation, title: string, path_map?: OpencodeV1PathMap, reverse_path_map?: OpencodeV1PathMap): Promise ---@field get_session fun(connection: OpencodeV1Connection, session_id: string, location: OpencodeLocation, path_map?: OpencodeV1PathMap, reverse_path_map?: OpencodeV1PathMap): Promise
diff --git a/lua/opencode/protocols/v2/operations.lua b/lua/opencode/protocols/v2/operations.lua index fe4110fa..2a9f23f4 100644 --- a/lua/opencode/protocols/v2/operations.lua +++ b/lua/opencode/protocols/v2/operations.lua @@ -109,14 +109,21 @@ function M.get_current_project(connection, location, path_map, reverse_path_map) end) end ----@param connection OpencodeV2Connection -function M.get_config(connection, location, path_map, reverse_path_map) - return json_request(connection, 'V2 get_config', 'GET', '/api/config', { - location = { directory = location_directory(location, path_map) }, - }):and_then(function(value) - return map_paths(require_table('V2 get_config', value), reverse_path_map) - end) -end +---@type fun(connection: OpencodeV2Connection, location: OpencodeLocation, path_map?: OpencodeV2PathMap, reverse_path_map?: OpencodeV2PathMap): Promise<{model?: string, agent: table}> +M.get_config = Promise.async(function(connection, location, path_map, reverse_path_map) + local model = M.get_default_model(connection, location, path_map, reverse_path_map):await() + local agents = M.list_agents(connection, location, path_map, reverse_path_map):await() + local config = { agent = {} } + if model then + config.model = model.providerID .. '/' .. model.id + end + for _, agent in ipairs(agents) do + config.agent[agent.id] = { + model = agent.model and (agent.model.providerID .. '/' .. agent.model.id), + } + end + return config +end) ---@param connection OpencodeV2Connection function M.list_providers(connection, location, path_map, reverse_path_map) @@ -224,8 +231,21 @@ function M.list_inbox(connection, session_id, reverse_path_map) end ---@param connection OpencodeV2Connection -function M.create_session(connection, location, input, path_map, reverse_path_map) +---@param selected? OpencodeSessionSelection +function M.create_session(connection, location, input, path_map, reverse_path_map, selected) local body = map_paths(type(input) == 'table' and vim.deepcopy(input) or {}, path_map) + if selected then + if body.agent == nil then + body.agent = selected.mode + end + if body.model == nil and body.agent == selected.mode and selected.model then + local provider, model = selected.model:match('^(.-)/(.+)$') + if not provider or not model then + error('V2 create_session model must use provider/model format') + end + body.model = { providerID = provider, id = model, variant = selected.variant } + end + end body.location = { directory = location_directory(location, path_map) } return json_request(connection, 'V2 create_session', 'POST', '/api/session', nil, body):and_then(function(value) local session = unwrap_data('V2 create_session', value, reverse_path_map) @@ -650,6 +670,9 @@ function M.get_default_model(connection, location, path_map, reverse_path_map) return json_request(connection, 'V2 get_default_model', 'GET', '/api/model/default', { location = { directory = location_directory(location, path_map) }, }):and_then(function(value) + if value.data == vim.NIL then + return nil + end return unwrap_data('V2 get_default_model', value, reverse_path_map) end) end @@ -843,7 +866,6 @@ end M.contract = { { 'GET', '/api/agent' }, { 'GET', '/api/command' }, - { 'GET', '/api/config' }, { 'GET', '/api/form' }, { 'GET', '/api/fs/find' }, { 'GET', '/api/location' }, diff --git a/lua/opencode/protocols/v2/types.lua b/lua/opencode/protocols/v2/types.lua index 5098a8fa..25bd354c 100644 --- a/lua/opencode/protocols/v2/types.lua +++ b/lua/opencode/protocols/v2/types.lua @@ -48,14 +48,14 @@ ---@class OpencodeV2Operations ---@field get_current_project fun(connection: OpencodeV2Connection, location: OpencodeLocation, path_map?: OpencodeV2PathMap, reverse_path_map?: OpencodeV2PathMap): Promise
----@field get_config fun(connection: OpencodeV2Connection, location: OpencodeLocation, path_map?: OpencodeV2PathMap, reverse_path_map?: OpencodeV2PathMap): Promise
+---@field get_config fun(connection: OpencodeV2Connection, location: OpencodeLocation, path_map?: OpencodeV2PathMap, reverse_path_map?: OpencodeV2PathMap): Promise<{model?: string, agent: table}> ---@field list_providers fun(connection: OpencodeV2Connection, location: OpencodeLocation, path_map?: OpencodeV2PathMap, reverse_path_map?: OpencodeV2PathMap): Promise<{location: OpencodeLocation, data: table[]}> ---@field list_sessions fun(connection: OpencodeV2Connection, location?: OpencodeLocation, cursor?: string, limit?: integer, path_map?: OpencodeV2PathMap, reverse_path_map?: OpencodeV2PathMap): Promise> ---@field list_sessions_project fun(connection: OpencodeV2Connection, location: OpencodeLocation, path_map?: OpencodeV2PathMap, reverse_path_map?: OpencodeV2PathMap): Promise ---@field list_sessions_global fun(connection: OpencodeV2Connection, reverse_path_map?: OpencodeV2PathMap): Promise ---@field list_active_sessions fun(connection: OpencodeV2Connection): Promise> ---@field list_inbox fun(connection: OpencodeV2Connection, session_id: string, reverse_path_map?: OpencodeV2PathMap): Promise ----@field create_session fun(connection: OpencodeV2Connection, location: OpencodeLocation, input?: table, path_map?: OpencodeV2PathMap, reverse_path_map?: OpencodeV2PathMap): Promise
+---@field create_session fun(connection: OpencodeV2Connection, location: OpencodeLocation, input?: table, path_map?: OpencodeV2PathMap, reverse_path_map?: OpencodeV2PathMap, selected?: OpencodeSessionSelection): Promise
---@field get_session fun(connection: OpencodeV2Connection, session_id: string, location?: OpencodeLocation, path_map?: OpencodeV2PathMap, reverse_path_map?: OpencodeV2PathMap): Promise
---@field delete_session fun(connection: OpencodeV2Connection, session_id: string): Promise ---@field rename_session fun(connection: OpencodeV2Connection, session_id: string, location: OpencodeLocation?, title: string): Promise @@ -81,7 +81,7 @@ ---@field cancel_question fun(connection: OpencodeV2Connection, session_id: string, request_id: string): Promise ---@field list_agents OpencodeV2LocationListOperation ---@field list_models OpencodeV2LocationListOperation ----@field get_default_model fun(connection: OpencodeV2Connection, location: OpencodeLocation, path_map?: OpencodeV2PathMap, reverse_path_map?: OpencodeV2PathMap): Promise
+---@field get_default_model fun(connection: OpencodeV2Connection, location: OpencodeLocation, path_map?: OpencodeV2PathMap, reverse_path_map?: OpencodeV2PathMap): Promise ---@field get_model_catalog fun(connection: OpencodeV2Connection, location: OpencodeLocation, path_map?: OpencodeV2PathMap, reverse_path_map?: OpencodeV2PathMap): Promise
---@field list_primary_agents fun(connection: OpencodeV2Connection, location: OpencodeLocation, path_map?: OpencodeV2PathMap, reverse_path_map?: OpencodeV2PathMap): Promise ---@field list_subagents fun(connection: OpencodeV2Connection, location: OpencodeLocation, path_map?: OpencodeV2PathMap, reverse_path_map?: OpencodeV2PathMap): Promise diff --git a/lua/opencode/services/agent_model.lua b/lua/opencode/services/agent_model.lua index 5cdb0239..f1dd7e4c 100644 --- a/lua/opencode/services/agent_model.lua +++ b/lua/opencode/services/agent_model.lua @@ -231,7 +231,10 @@ M.initialize_current_model = Promise.async(function(opts) if not is_current() then return end - if cfg and cfg.model and cfg.model ~= '' then + local mode_config = cfg and cfg.agent and cfg.agent[state.current_mode] + if mode_config and mode_config.model and mode_config.model ~= '' then + state.model.set_model(mode_config.model) + elseif cfg and cfg.model and cfg.model ~= '' then state.model.set_model(cfg.model) else local catalog = config_file.get_opencode_providers():await() @@ -249,6 +252,17 @@ M.initialize_current_model = Promise.async(function(opts) return state.current_model end) +---Resolve current choices and missing defaults for a new interactive session. +---Rejects when no primary agent is available or a lookup propagates an error. +---@type fun(): Promise +M.resolve_session_selection = Promise.async(function() + if not M.ensure_current_mode():await() then + error('Cannot resolve session agent') + end + M.initialize_current_model():await() + return { mode = state.current_mode, model = state.current_model, variant = state.current_variant } +end) + ---Apply a server-side model selection ({id or modelID, providerID, variant?}) ---to local state; same-value calls are no-ops. ---@param model? table diff --git a/lua/opencode/services/session_runtime.lua b/lua/opencode/services/session_runtime.lua index b81650be..a562a71b 100644 --- a/lua/opencode/services/session_runtime.lua +++ b/lua/opencode/services/session_runtime.lua @@ -435,7 +435,8 @@ M.open = Promise.async(function(opts) return Promise.new():resolve('ok') end) -local create_session = Promise.async(function(connection, location, title_or_opts) +---@param selected? OpencodeSessionSelection +local create_session = Promise.async(function(connection, location, title_or_opts, selected) local session_request = {} if type(title_or_opts) == 'string' then @@ -445,7 +446,7 @@ local create_session = Promise.async(function(connection, location, title_or_opt end local session_response = connection.operations - .create_session(connection, location, session_request, util.apply_path_map, util.apply_reverse_path_map) + .create_session(connection, location, session_request, util.apply_path_map, util.apply_reverse_path_map, selected) :catch(function(err) vim.notify('Error creating new session: ' .. vim.inspect(err), vim.log.levels.ERROR) end) @@ -460,7 +461,8 @@ end) ---@return Promise M.create_new_session = Promise.async(function(title_or_opts) local connection = server_job.ensure_server():await() - return create_session(connection, current_location(), title_or_opts):await() + local selected = agent_model.resolve_session_selection():await() + return create_session(connection, current_location(), title_or_opts, selected):await() end) ---@class OpencodeDetachedSession diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 5c12910b..c41dea11 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -10,6 +10,11 @@ ---@field code? string|number ---@field user_data? any +---@class OpencodeSessionSelection +---@field mode? string +---@field model? string +---@field variant? string + ---@class OpencodeConfigFile ---@field theme string ---@field autoshare boolean diff --git a/tests/unit/protocol_v1_operations_spec.lua b/tests/unit/protocol_v1_operations_spec.lua index c1455de6..82e8b855 100644 --- a/tests/unit/protocol_v1_operations_spec.lua +++ b/tests/unit/protocol_v1_operations_spec.lua @@ -85,7 +85,13 @@ describe('V1 protocol operations', function() return operations.list_sessions_global(connection, to_host):wait() end, create_session = function() - return operations.create_session(connection, location, { title = 'New' }, to_server, to_host):wait() + return operations + .create_session(connection, location, { title = 'New' }, to_server, to_host, { + mode = 'build', + model = 'provider/model', + variant = 'high', + }) + :wait() end, get_session = function() return operations.get_session(connection, 'ses-1', location, to_server, to_host):wait() diff --git a/tests/unit/protocol_v2_operations_spec.lua b/tests/unit/protocol_v2_operations_spec.lua index 9ccb7718..60e99ac7 100644 --- a/tests/unit/protocol_v2_operations_spec.lua +++ b/tests/unit/protocol_v2_operations_spec.lua @@ -219,7 +219,6 @@ describe('V2 protocol operations', function() it('uses direct, location/data, and page response contracts from the V2 fixtures', function() local bodies = { - ['/api/config'] = fixture('config.json'), ['/api/location'] = fixture('location.json'), ['/api/provider'] = fixture('provider.json'), ['/api/session'] = fixture('session.json'), @@ -238,14 +237,12 @@ describe('V2 protocol operations', function() return path:gsub('^/Users/oujinsai', '/host') end - local config = operations.get_config(connection, location, to_server, to_host):wait() local project = operations.get_current_project(connection, location, to_server, to_host):wait() local providers = operations.list_providers(connection, location, to_server, to_host):wait() local sessions = operations.list_sessions(connection, location, nil, 25, to_server, to_host):wait() local provider_fixture = vim.json.decode(bodies['/api/provider']) local session_fixture = vim.json.decode(bodies['/api/session']) - assert.equals('/host/.claude', config[1].path) assert.equals('/host/Projects/nvim-plugins/opencode.nvim', project.directory) assert.same(provider_fixture.location, providers.location) assert.same(provider_fixture.data, providers.data) @@ -253,8 +250,7 @@ describe('V2 protocol operations', function() assert.truthy(sessions.cursor.next) assert.equals('location%5Bdirectory%5D=%2Fserver%2Fworkspace', calls[1].request.query) assert.equals('location%5Bdirectory%5D=%2Fserver%2Fworkspace', calls[2].request.query) - assert.equals('location%5Bdirectory%5D=%2Fserver%2Fworkspace', calls[3].request.query) - assert.equals('directory=%2Fserver%2Fworkspace&limit=25', calls[4].request.query) + assert.equals('directory=%2Fserver%2Fworkspace&limit=25', calls[3].request.query) end) it('extracts the project object from the location envelope without unwrapping its data field', function() @@ -275,21 +271,64 @@ describe('V2 protocol operations', function() assert.equals('/host/project', result.directory) end) - it('does not unwrap a data field from a direct array response', function() - transport.request = function() - return Promise.new():resolve({ - status = 200, - body = '[{"data":{"belongs":"to-source"},"path":"/server/config.json"}]', - }) + it('resolves effective model defaults rather than treating config sources as config', function() + local calls = {} + transport.request = function(_, request) + calls[#calls + 1] = request + local bodies = { + ['/api/model/default'] = '{"data":{"providerID":"opencode-go","id":"glm-5.3-flash"}}', + ['/api/agent'] = '{"data":[{"id":"orchestrator","model":{"providerID":"custom","id":"agent-model"}},{"id":"build"}]}', + } + return Promise.new():resolve({ status = 200, body = assert(bodies[request.path]) }) end local result = operations - .get_config(ready_connection(), { directory = '/server/project' }, nil, function(path) - return path:gsub('^/server', '/host') + .get_config(ready_connection(), { directory = '/host/project' }, function(path) + return path:gsub('^/host', '/server') end) :wait() - assert.same({ belongs = 'to-source' }, result[1].data) - assert.equals('/host/config.json', result[1].path) + assert.equals('opencode-go/glm-5.3-flash', result.model) + assert.equals('custom/agent-model', result.agent.orchestrator.model) + assert.same({}, result.agent.build) + for _, call in ipairs(calls) do + assert.equals('location%5Bdirectory%5D=%2Fserver%2Fproject', call.query) + end + end) + + it('encodes neutral session selections without overriding explicit creation options', function() + local bodies = {} + transport.request = function(_, request) + bodies[#bodies + 1] = vim.json.decode(request.body) + return Promise.new():resolve({ status = 200, body = '{"data":{"id":"new-session"}}' }) + end + local connection = ready_connection() + local location = { directory = '/project' } + local selected = { mode = 'orchestrator', model = 'opencode-go/glm-5.3-flash', variant = 'high' } + operations.create_session(connection, location, { title = 'Default' }, nil, nil, selected):wait() + assert.same({ + title = 'Default', + location = location, + agent = 'orchestrator', + model = { providerID = 'opencode-go', id = 'glm-5.3-flash', variant = 'high' }, + }, bodies[1]) + + local explicit = { agent = 'other', model = { providerID = 'custom', id = 'chosen' } } + operations.create_session(connection, location, explicit, nil, nil, selected):wait() + assert.same({ agent = 'other', model = explicit.model, location = location }, bodies[2]) + assert.is_nil(explicit.location) + + operations.create_session(connection, location, { agent = 'other' }, nil, nil, selected):wait() + assert.same({ agent = 'other', location = location }, bodies[3]) + end) + + it('allows effective configuration without a default model', function() + transport.request = function(_, request) + return Promise.new():resolve({ + status = 200, + body = request.path == '/api/model/default' and '{"data":null}' or '{"data":[]}', + }) + end + assert.same({ agent = {} }, operations.get_config(ready_connection(), { directory = '/project' }):wait()) end) it('maps business data without changing a provider envelope location', function() diff --git a/tests/unit/services_agent_model_spec.lua b/tests/unit/services_agent_model_spec.lua index c2bcac1c..4aa12e95 100644 --- a/tests/unit/services_agent_model_spec.lua +++ b/tests/unit/services_agent_model_spec.lua @@ -185,6 +185,19 @@ describe('opencode.services.agent_model', function() config_file.get_opencode_providers:revert() end) + it('initializes the current agent model before the global default', function() + state.model.clear() + state.model.set_mode('orchestrator') + stub(config_file, 'get_opencode_config').returns(Promise.new():resolve({ + model = 'opencode/gemini-4-argon', + agent = { orchestrator = { model = 'opencode-go/glm-5.3-flash' } }, + })) + + assert.equal('opencode-go/glm-5.3-flash', agent_model.initialize_current_model():wait()) + + config_file.get_opencode_config:revert() + end) + it('restores the latest session model and mode when explicitly requested', function() state.model.set_model('openai/gpt-4.1') state.model.set_mode('plan') @@ -207,6 +220,30 @@ describe('opencode.services.agent_model', function() config_file.get_opencode_agents:revert() end) + it('resolves a neutral session selection while preserving explicit choices', function() + state.model.set_mode('plan') + state.model.set_model('custom/chosen') + state.model.set_variant('high') + stub(config_file, 'get_opencode_agents').returns(Promise.new():resolve({ 'plan' })) + + local selected = agent_model.resolve_session_selection():wait() + + assert.same({ mode = 'plan', model = 'custom/chosen', variant = 'high' }, selected) + config_file.get_opencode_agents:revert() + end) + + it('rejects session selection when no primary agent is available', function() + stub(config_file, 'get_opencode_agents').returns(Promise.new():resolve({})) + + local ok, err = pcall(function() + agent_model.resolve_session_selection():wait() + end) + + config_file.get_opencode_agents:revert() + assert.is_false(ok) + assert.matches('Cannot resolve session agent', tostring(err)) + end) + it('restores hidden mode from messages for child sessions', function() state.model.set_model('openai/gpt-4.1') state.model.set_mode('build') diff --git a/tests/unit/services_session_runtime_spec.lua b/tests/unit/services_session_runtime_spec.lua index 9dec0aa4..00e81c48 100644 --- a/tests/unit/services_session_runtime_spec.lua +++ b/tests/unit/services_session_runtime_spec.lua @@ -120,6 +120,65 @@ describe('opencode.services.session_runtime', function() end) end) + describe('create_new_session defaults', function() + for _, explicit in ipairs({ false, true }) do + it('passes neutral selections at creation, explicit options: ' .. tostring(explicit), function() + local connection = state.opencode_server + connection.protocol = 'v2' + state.model.clear() + state.model.set_mode_model_map({}) + local previous_mode = config.default_mode + config.default_mode = 'orchestrator' + local agents = stub(config_file, 'get_opencode_agents').returns(Promise.new():resolve({ 'orchestrator' })) + local defaults = stub(config_file, 'get_opencode_config').returns(Promise.new():resolve({ + model = 'opencode/gemini-4-argon', + agent = { orchestrator = { model = 'opencode-go/glm-5.3-flash' } }, + })) + local request, selection + connection.operations.create_session = function(_, _, input, _, _, selected) + request = input + selection = selected + return Promise.new():resolve({ + id = 'new-session', + model = input.model or { providerID = 'opencode-go', id = 'glm-5.3-flash' }, + }) + end + local input = explicit + and { + title = 'Explicit', + agent = 'other', + model = { providerID = 'custom', id = 'chosen', variant = 'high' }, + } + or 'Default' + local ok, err = pcall(function() + local session = session_runtime.create_new_session(input):wait() + assert.same(explicit and input or { title = 'Default' }, request) + assert.same({ mode = 'orchestrator', model = 'opencode-go/glm-5.3-flash' }, selection) + state.session.set_active(session) + connection.operations.get_session = function() + return Promise.new():resolve(session) + end + agent_model.sync_from_server():wait() + assert.equals(explicit and 'custom/chosen' or 'opencode-go/glm-5.3-flash', state.current_model) + end) + config.default_mode = previous_mode + agents:revert() + defaults:revert() + assert.is_true(ok, tostring(err)) + end) + end + + it('keeps V1 creation requests unchanged', function() + local request + state.opencode_server.operations.create_session = function(_, _, input) + request = input + return Promise.new():resolve({ id = 'v1-session' }) + end + assert.equals('v1-session', session_runtime.create_new_session('Legacy'):wait().id) + assert.same({ title = 'Legacy' }, request) + end) + end) + describe('open', function() it("creates windows if they don't exist", function() state.ui.set_windows(nil)