From db70c2f78af735c58c23d36b9ad806473d9f4bb5 Mon Sep 17 00:00:00 2001 From: jensenojs Date: Wed, 14 Oct 2026 22:04:46 +0800 Subject: [PATCH 1/2] feat(v2): consume agent.selected, revert.committed, compaction.ended --- .../protocols/v2/observation/messages.lua | 15 +++++++++- lua/opencode/services/agent_model.lua | 8 +++++ lua/opencode/services/session_runtime.lua | 15 ++++++++-- .../protocol_v2_observation_runtime_spec.lua | 30 +++++++++++++++++++ tests/unit/services_agent_model_spec.lua | 21 +++++++++++++ 5 files changed, 85 insertions(+), 4 deletions(-) diff --git a/lua/opencode/protocols/v2/observation/messages.lua b/lua/opencode/protocols/v2/observation/messages.lua index 9136e780..c92a0e61 100644 --- a/lua/opencode/protocols/v2/observation/messages.lua +++ b/lua/opencode/protocols/v2/observation/messages.lua @@ -383,6 +383,13 @@ end handlers['session.tool.success'] = finish_tool handlers['session.tool.failed'] = finish_tool +---@return string 'refetch' +local function invalidate_messages() + return 'refetch' +end +handlers['session.revert.committed'] = invalidate_messages +handlers['session.compaction.ended'] = invalidate_messages + ---@param observation OpencodeV2Observation ---@param event OpencodeV2Event ---@return boolean changed @@ -391,7 +398,13 @@ function M.ingest_event(observation, event) if not handler or event.data.sessionID ~= observation._session_id then return false end - if not handler(observation, event, event.data) then + local result = handler(observation, event, event.data) + if result == 'refetch' then + observation:read().sync.messages = { state = 'stale' } + observation:_start_resource('messages') + return true + end + if not result then return false end observation:read().sync.messages = { state = 'current' } diff --git a/lua/opencode/services/agent_model.lua b/lua/opencode/services/agent_model.lua index 88cb903c..5cdb0239 100644 --- a/lua/opencode/services/agent_model.lua +++ b/lua/opencode/services/agent_model.lua @@ -266,6 +266,14 @@ function M.apply_server_selection(model) end end +---@param agent? string +function M.apply_server_agent(agent) + if type(agent) ~= 'string' or agent == '' or state.current_mode == agent then + return + end + state.model.set_mode(agent) +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 diff --git a/lua/opencode/services/session_runtime.lua b/lua/opencode/services/session_runtime.lua index 3017a24d..b81650be 100644 --- a/lua/opencode/services/session_runtime.lua +++ b/lua/opencode/services/session_runtime.lua @@ -122,6 +122,7 @@ function M.setup_done_thinking_events(subscribe) }) end +---@param enable boolean|nil local function setup_model_sync_events(enable) vim.api.nvim_create_augroup('opencode_model_sync', { clear = true }) if not enable then @@ -129,14 +130,22 @@ local function setup_model_sync_events(enable) end vim.api.nvim_create_autocmd('User', { group = 'opencode_model_sync', - pattern = 'OpencodeEvent:session.model.selected', + pattern = { + 'OpencodeEvent:session.model.selected', + 'OpencodeEvent:session.agent.selected', + }, callback = function(args) - local props = args.data and args.data.event and args.data.event.properties + local event = args.data and args.data.event + local props = event and 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) + if event.type == 'session.agent.selected' then + agent_model.apply_server_agent(props.agent) + else + agent_model.apply_server_selection(props.model) + end end, }) end diff --git a/tests/unit/protocol_v2_observation_runtime_spec.lua b/tests/unit/protocol_v2_observation_runtime_spec.lua index 5c32d6ca..8083abed 100644 --- a/tests/unit/protocol_v2_observation_runtime_spec.lua +++ b/tests/unit/protocol_v2_observation_runtime_spec.lua @@ -1439,4 +1439,34 @@ describe('V2 protocol Observation runtime', function() assert.equals(operations, value.operations) stop() end) + + it('re-pulls the messages snapshot when the server rewrites the message set', function() + local pulls = 0 + local value = connection() + local streams = install_operations(value, { + list_messages = function() + pulls = pulls + 1 + return resolved({ data = {}, cursor = {} }) + end, + }) + local observed = value:observe({ id = 'ses-a' }) + local stop = observed:watch({ 'messages' }, function() end) + flush(function() + return observed:read().sync.messages.state == 'current' + end) + pulls = 0 + + emit(streams[1], event('ses-a', 'session.revert.committed', {})) + flush(function() + return observed:read().sync.messages.state == 'current' + end) + assert.is_true(pulls >= 1) + + emit(streams[1], event('ses-a', 'session.compaction.ended', {})) + flush(function() + return observed:read().sync.messages.state == 'current' + end) + assert.is_true(pulls >= 2) + stop() + end) end) diff --git a/tests/unit/services_agent_model_spec.lua b/tests/unit/services_agent_model_spec.lua index 312b0f55..c2bcac1c 100644 --- a/tests/unit/services_agent_model_spec.lua +++ b/tests/unit/services_agent_model_spec.lua @@ -314,4 +314,25 @@ describe('opencode.services.agent_model', function() assert.equal('anthropic/claude-3-opus', state.current_model) end) end) + + describe('apply_server_agent', function() + it('applies a server agent selection to the current mode', function() + state.store.set('current_mode', 'build') + agent_model.apply_server_agent('plan') + assert.equal('plan', state.current_mode) + end) + + it('keeps the local mode when the server selection matches', function() + state.store.set('current_mode', 'build') + agent_model.apply_server_agent('build') + assert.equal('build', state.current_mode) + end) + + it('ignores invalid agent refs', function() + state.store.set('current_mode', 'build') + agent_model.apply_server_agent(nil) + agent_model.apply_server_agent('') + assert.equal('build', state.current_mode) + end) + end) end) From b6be313fb4974f7e72dab83b0f4c2fa593a20232 Mon Sep 17 00:00:00 2001 From: jensenojs Date: Wed, 14 Oct 2026 22:28:38 +0800 Subject: [PATCH 2/2] fix(v2): follow revert staged/cleared from other clients --- .../protocols/v2/observation/events.lua | 13 +++++++++++ .../protocol_v2_observation_runtime_spec.lua | 23 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/lua/opencode/protocols/v2/observation/events.lua b/lua/opencode/protocols/v2/observation/events.lua index 74993ab7..180dc302 100644 --- a/lua/opencode/protocols/v2/observation/events.lua +++ b/lua/opencode/protocols/v2/observation/events.lua @@ -286,6 +286,19 @@ session_handlers['session.usage.updated'] = function(observation, _, data) observation:read().session.tokens = tokens end +session_handlers['session.revert.staged'] = function(observation, _, data) + if type(data.revert) ~= 'table' then + return boundary.diagnostic(observation, 'session', 'session.revert.staged is missing revert') + end + observation:read().session.revert = vim.deepcopy(data.revert) + return true +end + +session_handlers['session.revert.cleared'] = function(observation) + observation:read().session.revert = nil + return true +end + ---@param observation OpencodeV2Observation session_handlers['session.deleted'] = function(observation) observation:read().sync.session = lifecycle.sync_error('session_deleted', 'session was deleted') diff --git a/tests/unit/protocol_v2_observation_runtime_spec.lua b/tests/unit/protocol_v2_observation_runtime_spec.lua index 8083abed..9e1575c4 100644 --- a/tests/unit/protocol_v2_observation_runtime_spec.lua +++ b/tests/unit/protocol_v2_observation_runtime_spec.lua @@ -1372,6 +1372,29 @@ describe('V2 protocol Observation runtime', function() stop() end) + it('follows revert staging and clearing made in another client', function() + local value = connection() + local streams = install_operations(value) + local observed = value:observe({ id = 'ses-main' }) + local notifications = 0 + local stop = observed:watch({ 'session' }, function() + notifications = notifications + 1 + end) + flush(function() + return observed:read().sync.session.state == 'current' + end) + local before = notifications + + emit(streams[1], event('ses-main', 'session.revert.staged', { revert = { messageID = 'msg-1', files = {} } })) + assert.equals('msg-1', observed:read().session.revert.messageID) + assert.equals(before + 1, notifications) + + emit(streams[1], event('ses-main', 'session.revert.cleared', {})) + assert.is_nil(observed:read().session.revert) + assert.equals(before + 2, notifications) + stop() + end) + it('validates permission and form replies before calling their operations', function() local value = connection() local calls = {}