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)