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)