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
2 changes: 1 addition & 1 deletion lua/opencode/protocols/v1/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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<table[]>
---@field create_session fun(connection: OpencodeV1Connection, location: OpencodeLocation, input?: table, path_map?: OpencodeV1PathMap, reverse_path_map?: OpencodeV1PathMap): Promise<table>
---@field create_session fun(connection: OpencodeV1Connection, location: OpencodeLocation, input?: table, path_map?: OpencodeV1PathMap, reverse_path_map?: OpencodeV1PathMap, selected?: OpencodeSessionSelection): Promise<table>
---@field delete_session fun(connection: OpencodeV1Connection, session_id: string, location: OpencodeLocation, path_map?: OpencodeV1PathMap): Promise<boolean>
---@field rename_session fun(connection: OpencodeV1Connection, session_id: string, location: OpencodeLocation, title: string, path_map?: OpencodeV1PathMap, reverse_path_map?: OpencodeV1PathMap): Promise<boolean>
---@field get_session fun(connection: OpencodeV1Connection, session_id: string, location: OpencodeLocation, path_map?: OpencodeV1PathMap, reverse_path_map?: OpencodeV1PathMap): Promise<table>
Expand Down
42 changes: 32 additions & 10 deletions lua/opencode/protocols/v2/operations.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, {model?: string}>}>
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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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' },
Expand Down
6 changes: 3 additions & 3 deletions lua/opencode/protocols/v2/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,14 @@

---@class OpencodeV2Operations
---@field get_current_project fun(connection: OpencodeV2Connection, location: OpencodeLocation, path_map?: OpencodeV2PathMap, reverse_path_map?: OpencodeV2PathMap): Promise<table>
---@field get_config fun(connection: OpencodeV2Connection, location: OpencodeLocation, path_map?: OpencodeV2PathMap, reverse_path_map?: OpencodeV2PathMap): Promise<table>
---@field get_config fun(connection: OpencodeV2Connection, location: OpencodeLocation, path_map?: OpencodeV2PathMap, reverse_path_map?: OpencodeV2PathMap): Promise<{model?: string, agent: table<string, {model?: string}>}>
---@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<OpencodeV2Page<table>>
---@field list_sessions_project fun(connection: OpencodeV2Connection, location: OpencodeLocation, path_map?: OpencodeV2PathMap, reverse_path_map?: OpencodeV2PathMap): Promise<table[]>
---@field list_sessions_global fun(connection: OpencodeV2Connection, reverse_path_map?: OpencodeV2PathMap): Promise<table[]>
---@field list_active_sessions fun(connection: OpencodeV2Connection): Promise<table<string, {type: 'running'}>>
---@field list_inbox fun(connection: OpencodeV2Connection, session_id: string, reverse_path_map?: OpencodeV2PathMap): Promise<table[]>
---@field create_session fun(connection: OpencodeV2Connection, location: OpencodeLocation, input?: table, path_map?: OpencodeV2PathMap, reverse_path_map?: OpencodeV2PathMap): Promise<table>
---@field create_session fun(connection: OpencodeV2Connection, location: OpencodeLocation, input?: table, path_map?: OpencodeV2PathMap, reverse_path_map?: OpencodeV2PathMap, selected?: OpencodeSessionSelection): Promise<table>
---@field get_session fun(connection: OpencodeV2Connection, session_id: string, location?: OpencodeLocation, path_map?: OpencodeV2PathMap, reverse_path_map?: OpencodeV2PathMap): Promise<table>
---@field delete_session fun(connection: OpencodeV2Connection, session_id: string): Promise<boolean>
---@field rename_session fun(connection: OpencodeV2Connection, session_id: string, location: OpencodeLocation?, title: string): Promise<boolean>
Expand All @@ -81,7 +81,7 @@
---@field cancel_question fun(connection: OpencodeV2Connection, session_id: string, request_id: string): Promise<boolean>
---@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<table>
---@field get_default_model fun(connection: OpencodeV2Connection, location: OpencodeLocation, path_map?: OpencodeV2PathMap, reverse_path_map?: OpencodeV2PathMap): Promise<OpencodeV2ModelReference|nil>
---@field get_model_catalog fun(connection: OpencodeV2Connection, location: OpencodeLocation, path_map?: OpencodeV2PathMap, reverse_path_map?: OpencodeV2PathMap): Promise<table>
---@field list_primary_agents fun(connection: OpencodeV2Connection, location: OpencodeLocation, path_map?: OpencodeV2PathMap, reverse_path_map?: OpencodeV2PathMap): Promise<string[]>
---@field list_subagents fun(connection: OpencodeV2Connection, location: OpencodeLocation, path_map?: OpencodeV2PathMap, reverse_path_map?: OpencodeV2PathMap): Promise<string[]>
Expand Down
16 changes: 15 additions & 1 deletion lua/opencode/services/agent_model.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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<OpencodeSessionSelection>
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
Expand Down
8 changes: 5 additions & 3 deletions lua/opencode/services/session_runtime.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -460,7 +461,8 @@ end)
---@return Promise<OpencodeSession|nil>
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
Expand Down
5 changes: 5 additions & 0 deletions lua/opencode/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion tests/unit/protocol_v1_operations_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
69 changes: 54 additions & 15 deletions tests/unit/protocol_v2_operations_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -238,23 +237,20 @@ 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)
assert.equals(to_host(session_fixture.data[1].location.directory), sessions.data[1].location.directory)
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()
Expand All @@ -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()
Expand Down
37 changes: 37 additions & 0 deletions tests/unit/services_agent_model_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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')
Expand Down
Loading
Loading