From 1dd0ebc3b4c65bc4ef84a9bf34231718778b1074 Mon Sep 17 00:00:00 2001 From: jensenojs Date: Wed, 14 Oct 2026 20:25:09 +0800 Subject: [PATCH 1/4] fix(v2): synthesize stable part identity for text/reasoning/tool/file content --- lua/opencode/protocols/v2/normalize.lua | 42 +++-- .../protocols/v2/observation/messages.lua | 14 +- lua/opencode/services/session_runtime.lua | 5 +- lua/opencode/ui/formatter.lua | 5 +- lua/opencode/ui/reference_facts.lua | 9 +- tests/unit/protocol_v2_observation_spec.lua | 143 +++++++++++++++++- tests/unit/reference_facts_spec.lua | 52 +++++++ tests/unit/renderer_context_spec.lua | 15 ++ 8 files changed, 249 insertions(+), 36 deletions(-) diff --git a/lua/opencode/protocols/v2/normalize.lua b/lua/opencode/protocols/v2/normalize.lua index 4b6e124ca..5f45fd1d7 100644 --- a/lua/opencode/protocols/v2/normalize.lua +++ b/lua/opencode/protocols/v2/normalize.lua @@ -12,8 +12,7 @@ local function first(value, keys) end end ---- Validates `value` against shorthand or explicit schemas. Errors from this ---- module get the same context prefix as the older inline guards. +--- Validates `value` against shorthand or explicit schemas. local function shape(value, spec, message) local full_message = message and 'V2 observation: ' .. message or nil return v.validate(value, spec, full_message) @@ -543,7 +542,6 @@ local function mapped_tool(part) tool_part_shape:parse(part, 'V2 observation: invalid assistant tool content') local status = part.state.status local result = { - id = part.id, kind = 'tool', call_id = part.id, name = part.name, @@ -640,20 +638,22 @@ local function append_user_file(entry, file, index, text) if context_name == 'review_comment' then context_name = 'review-comment' end + + local part if not context_name then - entry.content[#entry.content + 1] = mapped_file(file, text) - return + part = mapped_file(file, text) + else + -- Editor-context attachments are mapped to the same contract as V1 synthetic parts. + editor_context_file_shape:parse(file, 'V2 observation: invalid editor context attachment') + local context_entry, decode_err = + shared_decode_editor_context(context_name, vim.base64.decode(file.data), file.name, true, file.ignored) + v.expect(not decode_err, 'V2 observation: ' .. tostring(decode_err)) + v.expect(context_entry ~= nil, 'V2 observation: invalid ' .. tostring(context_name) .. ' editor context attachment') + ---@cast context_entry table + part = context_entry end - - -- Editor-context attachments are mapped to the same contract as V1 synthetic parts. - editor_context_file_shape:parse(file, 'V2 observation: invalid editor context attachment') - local context_entry, decode_err = - shared_decode_editor_context(context_name, vim.base64.decode(file.data), file.name, true, file.ignored) - v.expect(not decode_err, 'V2 observation: ' .. tostring(decode_err)) - v.expect(context_entry ~= nil, 'V2 observation: invalid ' .. tostring(context_name) .. ' editor context attachment') - ---@cast context_entry table - context_entry.id = file.name .. '#' .. index - entry.content[#entry.content + 1] = context_entry + part.id = vim.json.encode({ entry.id, 'file', index }) + entry.content[#entry.content + 1] = part end local function mapped_user(entry, info) @@ -703,8 +703,18 @@ local function mapped_assistant(entry, info) error = mapped_error(info.retry.error), } end + local ordinals = { text = 0, reasoning = 0 } for _, part in ipairs(info.content) do - entry.content[#entry.content + 1] = mapped_assistant_content(part) + local content = mapped_assistant_content(part) + local coordinate + if part.type == 'tool' then + coordinate = content.call_id + else + coordinate = ordinals[part.type] + ordinals[part.type] = coordinate + 1 + end + content.id = vim.json.encode({ info.id, part.type, coordinate }) + entry.content[#entry.content + 1] = content end return entry end diff --git a/lua/opencode/protocols/v2/observation/messages.lua b/lua/opencode/protocols/v2/observation/messages.lua index c92a0e61c..a07ae3199 100644 --- a/lua/opencode/protocols/v2/observation/messages.lua +++ b/lua/opencode/protocols/v2/observation/messages.lua @@ -22,8 +22,8 @@ local function rebuild_content_index(observation, entry) if ordinals[content.kind] then index[content_key(content.kind, ordinals[content.kind])] = content ordinals[content.kind] = ordinals[content.kind] + 1 - elseif content.kind == 'tool' and content.id then - index['tool:' .. content.id] = content + elseif content.kind == 'tool' and content.call_id then + index['tool:' .. content.call_id] = content end end observation._v2_content_by_message[entry.id] = index @@ -139,7 +139,7 @@ local function ordinal_content(observation, data, event_type, create) if content or not create then return content end - content = { kind = kind, text = '' } + content = { id = vim.json.encode({ entry.id, kind, data.ordinal }), kind = kind, text = '' } entry.content[#entry.content + 1] = content index[key] = content return content @@ -170,7 +170,13 @@ local function tool_content(observation, data, event_type, create) invalid(observation, event_type, 'is missing tool name') return nil end - content = { id = data.id, kind = 'tool', call_id = data.id, name = data.name, state = 'streaming' } + content = { + id = vim.json.encode({ entry.id, 'tool', data.id }), + kind = 'tool', + call_id = data.id, + name = data.name, + state = 'streaming', + } entry.content[#entry.content + 1] = content index['tool:' .. data.id] = content return content diff --git a/lua/opencode/services/session_runtime.lua b/lua/opencode/services/session_runtime.lua index b81650be4..dc6728665 100644 --- a/lua/opencode/services/session_runtime.lua +++ b/lua/opencode/services/session_runtime.lua @@ -428,8 +428,9 @@ M.open = Promise.async(function(opts) return Promise.new():reject(err) end - -- Re-sync model/variant from the server: the user may have switched them - -- in the TUI while this client was unfocused (no SSE broadcast on switch). + -- Re-sync model/variant from the server: switch events only carry increments, + -- so anything the user changed in the TUI while this client was unfocused + -- converges only through this GET on open. agent_model.sync_from_server() return Promise.new():resolve('ok') diff --git a/lua/opencode/ui/formatter.lua b/lua/opencode/ui/formatter.lua index fa83fa2fb..c0b74846f 100644 --- a/lua/opencode/ui/formatter.lua +++ b/lua/opencode/ui/formatter.lua @@ -659,17 +659,18 @@ local function part_text_trim_offset(part, text) end local function current_part_text_references(part, message, text, context) - if not (part and part.id and message and message.id and context and context.current_refs) then + if not (context and context.current_refs) then return {} end local trim_offset = part_text_trim_offset(part, text) + local message_id = message and message.id local refs = {} for _, ref in ipairs(context.current_refs) do local raw_range = ref.raw_range if ref.source_kind == 'assistant_text' - and ref.message_id == message.id + and ref.message_id == message_id and ref.part_id == part.id and raw_range then diff --git a/lua/opencode/ui/reference_facts.lua b/lua/opencode/ui/reference_facts.lua index 3418323ec..d15ebb0a6 100644 --- a/lua/opencode/ui/reference_facts.lua +++ b/lua/opencode/ui/reference_facts.lua @@ -47,18 +47,11 @@ local function is_current_session_user_message(session_id, message) end local function collect_part_refs(session_id, message, part, message_order, part_order) - if not part then - return {} - end - if is_current_session_user_message(session_id, message) and part.kind == 'file' then local path = part.source and part.source.path or part.name if not path or path == '' then return {} end - if not part.id then - return {} - end return { { session_id = session_id, @@ -71,7 +64,7 @@ local function collect_part_refs(session_id, message, part, message_order, part_ } end - if not is_current_session_assistant_message(session_id, message) or part.synthetic or not part.id then + if not is_current_session_assistant_message(session_id, message) or part.synthetic then return {} end diff --git a/tests/unit/protocol_v2_observation_spec.lua b/tests/unit/protocol_v2_observation_spec.lua index fbb2f0462..e5259b38b 100644 --- a/tests/unit/protocol_v2_observation_spec.lua +++ b/tests/unit/protocol_v2_observation_spec.lua @@ -151,8 +151,11 @@ describe('V2 protocol Observation interpretation', function() assert.same({ created = 200, streamed = 220, completed = 240 }, reply.time) assert.same({ start = 'snap-start', ['end'] = 'snap-end', files = { 'main.lua' } }, reply.snapshot) assert.same({ input = 10, output = 5, reasoning = 2, cache = { read = 3, write = 1 } }, reply.tokens) + assert.equals('["msg-assistant","reasoning",0]', reply.content[1].id) + assert.equals('["msg-assistant","text",0]', reply.content[2].id) local tool = reply.content[3] - assert.equals('tool-1', tool.id) + assert.equals('["msg-assistant","tool","tool-1"]', tool.id) + assert.equals('tool-1', tool.call_id) assert.equals('completed', tool.state) assert.is_nil(tool.metadata) assert.same({ { path = 'main.lua', diff = '@@ -9,1 +9,1 @@\n-old\n+new' } }, tool.changes) @@ -452,7 +455,7 @@ describe('V2 protocol Observation interpretation', function() assert.equals('from another client', observed:read().entries_by_id['msg-user'].content[1].text) end) - it('applies native text, reasoning, and tool lifecycles without synthetic identities', function() + it('applies native text, reasoning, and tool lifecycles with synthesized identities', function() local observed = observation('ses-target') assert.is_true(observation_module.ingest_event( observed, @@ -538,8 +541,11 @@ describe('V2 protocol Observation interpretation', function() assert.equals('AR', entry.content[1].text) assert.equals('BT', entry.content[2].text) assert.equals('C', entry.content[3].text) - assert.is_nil(entry.content[1].id) - assert.is_nil(entry.content[2].id) + assert.equals('["msg-live","reasoning",0]', entry.content[1].id) + assert.equals('["msg-live","text",0]', entry.content[2].id) + assert.equals('["msg-live","reasoning",1]', entry.content[3].id) + assert.equals('["msg-live","tool","tool-live"]', entry.content[4].id) + assert.equals('tool-live', entry.content[4].call_id) assert.equals('completed', entry.content[4].state) assert.is_nil(entry.content[4].metadata) assert.same({ { path = 'live.lua', diff = '@@ -3,1 +3,1 @@\n-old\n+new' } }, entry.content[4].changes) @@ -633,6 +639,133 @@ describe('V2 protocol Observation interpretation', function() assert.is_nil(observed:read().entries_by_id['msg-missing']) assert.matches('no assistant message', observed:read().sync.messages.error.message) end) + it('keeps synthesized part identity stable across the event-to-snapshot handoff', function() + local observed = observation('ses-target') + observation_module.ingest_event( + observed, + event('ses-target', 'session.step.started', { + assistantMessageID = 'msg-mix', + agent = 'build', + model = { providerID = 'p', id = 'm' }, + }) + ) + observation_module.ingest_event( + observed, + event('ses-target', 'session.text.started', { assistantMessageID = 'msg-mix', ordinal = 0 }) + ) + observation_module.ingest_event( + observed, + event('ses-target', 'session.text.delta', { assistantMessageID = 'msg-mix', ordinal = 0, delta = 'T0' }) + ) + observation_module.ingest_event( + observed, + event( + 'ses-target', + 'session.tool.input.started', + { assistantMessageID = 'msg-mix', id = 'tool-mix', name = 'bash' } + ) + ) + observation_module.ingest_event( + observed, + event('ses-target', 'session.reasoning.started', { assistantMessageID = 'msg-mix', ordinal = 0 }) + ) + observation_module.ingest_event( + observed, + event('ses-target', 'session.text.started', { assistantMessageID = 'msg-mix', ordinal = 1 }) + ) + + local entry = observed:read().entries_by_id['msg-mix'] + assert.same( + { 'text', 'tool', 'reasoning', 'text' }, + vim.tbl_map(function(content) + return content.kind + end, entry.content) + ) + assert.same( + { + '["msg-mix","text",0]', + '["msg-mix","tool","tool-mix"]', + '["msg-mix","reasoning",0]', + '["msg-mix","text",1]', + }, + vim.tbl_map(function(content) + return content.id + end, entry.content) + ) + + observation_module.ingest_snapshot(observed, { + { + id = 'msg-mix', + type = 'assistant', + agent = 'build', + model = { providerID = 'p', id = 'm' }, + time = { created = 300, completed = 400 }, + snapshot = {}, + content = { + { type = 'text', text = 'snap-t0' }, + { + type = 'tool', + id = 'tool-mix', + name = 'bash', + time = { created = 310 }, + state = { status = 'running', input = { command = 'ls' } }, + }, + { type = 'reasoning', text = 'snap-r0' }, + { type = 'text', text = 'snap-t1' }, + }, + }, + }) + local replaced = observed:read().entries_by_id['msg-mix'] + assert.same( + { + '["msg-mix","text",0]', + '["msg-mix","tool","tool-mix"]', + '["msg-mix","reasoning",0]', + '["msg-mix","text",1]', + }, + vim.tbl_map(function(content) + return content.id + end, replaced.content) + ) + + assert.is_true( + observation_module.ingest_event( + observed, + event('ses-target', 'session.text.delta', { assistantMessageID = 'msg-mix', ordinal = 0, delta = '!' }) + ) + ) + assert.is_true( + observation_module.ingest_event( + observed, + event('ses-target', 'session.text.ended', { assistantMessageID = 'msg-mix', ordinal = 0, text = 'T0-final' }) + ) + ) + assert.equals('T0-final', replaced.content[1].text) + assert.equals('["msg-mix","text",0]', replaced.content[1].id) + assert.equals('snap-t1', replaced.content[4].text) + + assert.is_true(observation_module.ingest_event( + observed, + event('ses-target', 'session.tool.called', { + assistantMessageID = 'msg-mix', + id = 'tool-mix', + input = { command = 'ls -a' }, + executed = false, + }) + )) + assert.is_true(observation_module.ingest_event( + observed, + event('ses-target', 'session.tool.success', { + assistantMessageID = 'msg-mix', + id = 'tool-mix', + executed = true, + content = { { type = 'text', text = 'done' } }, + }) + )) + assert.equals('["msg-mix","tool","tool-mix"]', replaced.content[2].id) + assert.equals('tool-mix', replaced.content[2].call_id) + assert.equals('completed', replaced.content[2].state) + end) end) describe('V2 protocol editor-context attachments', function() @@ -674,9 +807,11 @@ describe('V2 protocol editor-context attachments', function() local context_entry = entry.content[2] assert.same('editor_context', context_entry.kind) assert.is_true(context_entry.synthetic) + assert.equals('["msg-user","file",1]', context_entry.id) assert.same('selection', context_entry.source.kind) assert.same('test.py', context_entry.source.file_name) assert.same('1-2', context_entry.source.range) assert.same('selected code', context_entry.text) + assert.equals('["msg-user","file",2]', entry.content[3].id) end) end) diff --git a/tests/unit/reference_facts_spec.lua b/tests/unit/reference_facts_spec.lua index 8b0fdb3cc..aeb37b5ae 100644 --- a/tests/unit/reference_facts_spec.lua +++ b/tests/unit/reference_facts_spec.lua @@ -130,6 +130,58 @@ describe('opencode.ui.reference_facts', function() assert.are.same({}, reference_facts.current_files()) end) + it('collects v2 synthesized identities and still excludes id-less attachments', function() + rebuild({ + { + id = 'user_1', + kind = 'user', + session_id = 'ses_1', + content = { + { kind = 'text', text = 'look' }, + { id = vim.json.encode({ 'user_1', 'file', 1 }), kind = 'file', name = 'src/ok.lua' }, + }, + }, + assistant_message('msg_1', 'ses_1', { + { id = vim.json.encode({ 'msg_1', 'text', 0 }), kind = 'text', text = 'See `src/ok.lua`.' }, + { kind = 'agent', name = 'review' }, + { kind = 'skill', skill_id = 's', name = 'review', text = 'x' }, + }), + }) + + local refs = reference_facts.current_refs() + + assert.equal(2, #refs) + assert.equal('user_file_part', refs[1].source_kind) + assert.equal('["user_1","file",1]', refs[1].part_id) + assert.equal('assistant_text', refs[2].source_kind) + assert.equal('["msg_1","text",0]', refs[2].part_id) + end) + + it('isolates identical raw tool ids across messages under synthesized identities', function() + rebuild({ + assistant_message('msg_a', 'ses_1', { + { id = vim.json.encode({ 'msg_a', 'tool', 'shared' }), kind = 'tool', target = { path = '/repo/src/ok.lua' } }, + }), + assistant_message('msg_b', 'ses_1', { + { + id = vim.json.encode({ 'msg_b', 'tool', 'shared' }), + kind = 'tool', + target = { path = '/repo/src/tool.lua' }, + }, + }), + }) + + local refs = reference_facts.current_refs() + + assert.equal(2, #refs) + assert.equal('msg_a', refs[1].message_id) + assert.equal('["msg_a","tool","shared"]', refs[1].part_id) + assert.equal('src/ok.lua', refs[1].path) + assert.equal('msg_b', refs[2].message_id) + assert.equal('["msg_b","tool","shared"]', refs[2].part_id) + assert.equal('src/tool.lua', refs[2].path) + end) + it('available_files merges readable ref files with loaded plain buffers', function() local dedup_buf = vim.api.nvim_create_buf(false, true) vim.bo[dedup_buf].buftype = '' diff --git a/tests/unit/renderer_context_spec.lua b/tests/unit/renderer_context_spec.lua index 56a579e87..2bfc2d542 100644 --- a/tests/unit/renderer_context_spec.lua +++ b/tests/unit/renderer_context_spec.lua @@ -255,3 +255,18 @@ describe('renderer context ownership', function() assert.equals(1, two.releases) end) end) + +describe('renderer content keys', function() + it('keys parts by their synthesized identity and falls back to position for parts without id', function() + local entry = { + id = 'msg-a', + content = { + { id = vim.json.encode({ 'msg-a', 'text', 0 }), kind = 'text', text = '' }, + { kind = 'text', text = 'user text' }, + }, + } + local instance = contexts.new() + assert.equals('["msg-a","text",0]', instance.content_key(entry, 1)) + assert.equals('msg-a:content:2', instance.content_key(entry, 2)) + end) +end) From c33115159cc4754104c2e0584233f966a4734126 Mon Sep 17 00:00:00 2001 From: jensenojs Date: Wed, 14 Oct 2026 20:25:20 +0800 Subject: [PATCH 2/4] chore(ui): surface silent failure paths in observation and symbol snapshot --- lua/opencode/protocols/v1/observation.lua | 2 + .../protocols/v2/observation/boundary.lua | 2 + .../protocols/v2/observation/messages.lua | 18 +- lua/opencode/ui/navigation.lua | 13 +- lua/opencode/ui/symbol_snapshot.lua | 58 ++++++- tests/unit/observation_logging_spec.lua | 161 ++++++++++++++++++ 6 files changed, 245 insertions(+), 9 deletions(-) create mode 100644 tests/unit/observation_logging_spec.lua diff --git a/lua/opencode/protocols/v1/observation.lua b/lua/opencode/protocols/v1/observation.lua index 86215f482..bf25ae586 100644 --- a/lua/opencode/protocols/v1/observation.lua +++ b/lua/opencode/protocols/v1/observation.lua @@ -13,6 +13,7 @@ local mapped_permission = normalize.mapped_permission local mapped_question = normalize.mapped_question local lifecycle = require('opencode.protocols.observation') +local log = require('opencode.log') local id = require('opencode.id') local Promise = require('opencode.promise') local util = require('opencode.util') @@ -119,6 +120,7 @@ local function record_diagnostic(observation, message) state = 'error', error = { kind = 'protocol_contract', message = message }, } + log.warn('V1 observation diagnostic (messages): %s', message) end local function remove_from_order(order, item_id) diff --git a/lua/opencode/protocols/v2/observation/boundary.lua b/lua/opencode/protocols/v2/observation/boundary.lua index 0338a7c57..f29f6d5fc 100644 --- a/lua/opencode/protocols/v2/observation/boundary.lua +++ b/lua/opencode/protocols/v2/observation/boundary.lua @@ -1,4 +1,5 @@ local lifecycle = require('opencode.protocols.observation') +local log = require('opencode.log') local M = {} @@ -14,6 +15,7 @@ end ---@return false function M.diagnostic(observation, resource, message) observation:read().sync[resource] = lifecycle.sync_error('protocol_contract', message) + log.warn('V2 observation diagnostic (%s): %s', resource, message) return false end diff --git a/lua/opencode/protocols/v2/observation/messages.lua b/lua/opencode/protocols/v2/observation/messages.lua index a07ae3199..0f24e1500 100644 --- a/lua/opencode/protocols/v2/observation/messages.lua +++ b/lua/opencode/protocols/v2/observation/messages.lua @@ -2,6 +2,7 @@ local entries = require('opencode.protocols.entries') local Promise = require('opencode.promise') local normalize = require('opencode.protocols.v2.normalize') local boundary = require('opencode.protocols.v2.observation.boundary') +local log = require('opencode.log') local M = {} @@ -396,12 +397,27 @@ end handlers['session.revert.committed'] = invalidate_messages handlers['session.compaction.ended'] = invalidate_messages +-- Upstream adds and renames session event families independently of this plugin; +-- record each unknown type once so a new gap is discoverable without flooding the log. +local unknown_session_events = {} + ---@param observation OpencodeV2Observation ---@param event OpencodeV2Event ---@return boolean changed function M.ingest_event(observation, event) local handler = handlers[event.type] - if not handler or event.data.sessionID ~= observation._session_id then + if not handler then + if + not unknown_session_events[event.type] + and event.type:sub(1, #'session.') == 'session.' + and event.data.sessionID == observation._session_id + then + unknown_session_events[event.type] = true + log.info('unhandled session event type: %s', event.type) + end + return false + end + if event.data.sessionID ~= observation._session_id then return false end local result = handler(observation, event, event.data) diff --git a/lua/opencode/ui/navigation.lua b/lua/opencode/ui/navigation.lua index 1fc1f9436..e3c97a390 100644 --- a/lua/opencode/ui/navigation.lua +++ b/lua/opencode/ui/navigation.lua @@ -3,6 +3,7 @@ local M = {} local state = require('opencode.state') local config = require('opencode.config') local renderer = require('opencode.ui.renderer') +local log = require('opencode.log') ---@param win integer local function mark_jump_position(win) @@ -286,12 +287,14 @@ local function jump_to_symbol_target(target) local symbol_snapshot = require('opencode.ui.symbol_snapshot') -- Keypress-time lookup re-reads the current available file set; the set frozen -- on the rendered target may be stale (buffers/files changed since render). - local targets = symbol_snapshot.targets_for_token( - symbol_snapshot.new_cycle(), - target.token, - require('opencode.ui.reference_facts').available_files() - ) + local available_files = require('opencode.ui.reference_facts').available_files() + local targets = symbol_snapshot.targets_for_token(symbol_snapshot.new_cycle(), target.token, available_files) if #targets == 0 then + if #available_files == 0 then + log.debug('symbol jump: no candidate files for token %s', target.token) + else + log.debug('symbol jump: token %s has no match in %d candidate files', target.token, #available_files) + end vim.notify('No symbol target found: ' .. target.token, vim.log.levels.INFO) return end diff --git a/lua/opencode/ui/symbol_snapshot.lua b/lua/opencode/ui/symbol_snapshot.lua index 9dd90ca91..dea62182e 100644 --- a/lua/opencode/ui/symbol_snapshot.lua +++ b/lua/opencode/ui/symbol_snapshot.lua @@ -1,3 +1,5 @@ +local log = require('opencode.log') + local M = {} ---@class SymbolReferenceTarget @@ -21,6 +23,9 @@ local function source_version(path) local bufnr = vim.fn.bufnr and vim.fn.bufnr(path) or -1 if bufnr and bufnr > 0 and vim.api.nvim_buf_is_loaded and vim.api.nvim_buf_is_loaded(bufnr) then local ok, changedtick = pcall(vim.api.nvim_buf_get_changedtick, bufnr) + if not ok then + log.debug('symbol snapshot: buffer changedtick read failed: %s', tostring(changedtick)) + end return ok and 'buffer:' .. bufnr .. ':' .. changedtick or nil end @@ -66,10 +71,14 @@ local function current_source_root(path, lang) end) if parser_ok then parser = buffer_parser + else + log.debug('symbol snapshot: buffer parser creation failed: %s', tostring(buffer_parser)) end else local read_ok, lines = pcall(vim.fn.readfile, path) - if read_ok and type(lines) == 'table' then + if not read_ok then + log.debug('symbol snapshot: file read failed: %s', tostring(lines)) + elseif type(lines) == 'table' then local content = table.concat(lines, '\n') source = content local parser_ok, string_parser = pcall(function() @@ -79,6 +88,8 @@ local function current_source_root(path, lang) end) if parser_ok then parser = string_parser + else + log.debug('symbol snapshot: string parser creation failed: %s', tostring(string_parser)) end end end @@ -90,11 +101,15 @@ local function current_source_root(path, lang) local parse_ok, trees = pcall(function() return parser:parse() end) + if not parse_ok then + log.debug('symbol snapshot: tree parse failed: %s', tostring(trees)) + end local tree = parse_ok and trees and trees[1] or nil local root_ok, root = pcall(function() return tree and tree:root() or nil end) if not root_ok then + log.debug('symbol snapshot: tree root failed: %s', tostring(root)) return nil, nil end @@ -159,6 +174,9 @@ local function collect_path(path) return vim.treesitter.language.get_lang(filetype) end end) + if not lang_ok then + log.debug('symbol snapshot: parser language lookup failed: %s', tostring(parser_lang)) + end if lang_ok and parser_lang then lang = parser_lang end @@ -175,7 +193,11 @@ local function collect_path(path) return vim.treesitter.query.get(lang, 'locals') end end) - if not query_ok or not query then + if not query_ok then + log.debug('symbol snapshot: query compile failed for %s: %s', lang, tostring(query)) + return by_token + end + if not query then return by_token end @@ -187,16 +209,30 @@ local function collect_path(path) local iter_ok, iter, iter_state, iter_initial = pcall(function() return query:iter_captures(root, source, 0, -1) end) - if not iter_ok or not iter then + if not iter_ok then + log.debug('symbol snapshot: capture iterator creation failed: %s', tostring(iter)) return by_token end + if not iter then + return by_token + end + + local capture_counts = {} + local capture_total = 0 for capture_id, node in iter, iter_state, iter_initial do + ---@type string? local capture = query.captures and query.captures[capture_id] + capture_total = capture_total + 1 + local capture_name = capture or ('?capture:' .. tostring(capture_id)) + capture_counts[capture_name] = (capture_counts[capture_name] or 0) + 1 local kind = capture and capture:match('^local%.definition%.(.+)$') local text_ok, token = pcall(function() return vim.treesitter.get_node_text(node, source) end) + if not text_ok then + log.debug('symbol snapshot: node text extraction failed: %s', tostring(token)) + end if kind and kind ~= 'associated' and text_ok and definition_token(token) then local row, col = node:range() local targets = by_token[token] @@ -214,6 +250,22 @@ local function collect_path(path) end end + -- Distribution of capture names as this Neovim actually resolved them: a + -- silent treesitter difference (renamed captures, a stripped 'locals' query) + -- shows up here as missing names while every call still "succeeds". + local distribution = {} + for name, count in pairs(capture_counts) do + distribution[#distribution + 1] = name .. 'x' .. count + end + table.sort(distribution) + log.debug( + 'symbol snapshot: %s: %d captures (%s), %d definition tokens', + path, + capture_total, + #distribution > 0 and table.concat(distribution, ', ') or 'none', + #vim.tbl_keys(by_token) + ) + if cache_key then path_cache:set(path, { version = cache_key, by_token = by_token }) end diff --git a/tests/unit/observation_logging_spec.lua b/tests/unit/observation_logging_spec.lua new file mode 100644 index 000000000..a2831c1eb --- /dev/null +++ b/tests/unit/observation_logging_spec.lua @@ -0,0 +1,161 @@ +-- Contract violations and unknown session events must leave traces in the log file. +-- log.lua captures its path and level at require time, and tests/minimal/init.lua +-- already loaded it (pointed at the real stdpath), so this spec points config.logging +-- at a temp file, purges the affected modules, and re-requires them before exercising +-- the observation entry points. +local assert = require('luassert') +local config = require('opencode.config') + +local original_logging = { + enabled = config.logging.enabled, + level = config.logging.level, + outfile = config.logging.outfile, +} + +local logging_logfile = vim.fn.tempname() .. '-observation-logging.log' + +local function point_logging_at_spec_file() + config.logging.enabled = true + config.logging.level = 'debug' + config.logging.outfile = logging_logfile +end + +local function restore_logging() + config.logging.enabled = original_logging.enabled + config.logging.level = original_logging.level + config.logging.outfile = original_logging.outfile +end + +point_logging_at_spec_file() + +for _, name in ipairs({ + 'opencode.log', + 'opencode.protocols.observation', + 'opencode.protocols.v1.observation', + 'opencode.protocols.v2.observation', + 'opencode.protocols.v2.observation.messages', + 'opencode.protocols.v2.observation.boundary', +}) do + package.loaded[name] = nil +end + +local log = require('opencode.log') +local v1_observation = require('opencode.protocols.v1.observation') +local v2_observation = require('opencode.protocols.v2.observation') + +local function read_log() + local path = log.get_path() + local file = path and io.open(path, 'r') + if not file then + return '' + end + local content = file:read('*a') + file:close() + return content or '' +end + +local function count_occurrences(content, pattern) + local count = 0 + for _ in content:gmatch(pattern) do + count = count + 1 + end + return count +end + +local function v1_observation_for(session_id) + local connection = require('opencode.opencode_server').from_custom('http://v1.test') + connection.protocol = 'v1' + connection.server_identity = { version = '1.18.30' } + connection.credential = { username = 'opencode' } + connection:mark_ready() + return connection:observe({ + id = session_id, + location = { directory = '/server/project' }, + }) +end + +local function v1_contract() + local path = vim.fn.getcwd() .. '/tests/data/v1/observation-1.18.json' + return vim.json.decode(table.concat(vim.fn.readfile(path), '\n')) +end + +local function v2_observation_for(session_id) + local connection = require('opencode.opencode_server').from_custom('http://v2.test') + connection.protocol = 'v2' + connection.server_identity = { version = '2.0.1' } + connection.credential = { username = 'opencode' } + connection:mark_ready() + return connection:observe({ id = session_id }) +end + +local function v2_event(session_id, kind, data, created) + data.sessionID = session_id + return { id = 'evt-fixed', type = kind, created = created or 300, data = data } +end + +describe('observation logging', function() + before_each(point_logging_at_spec_file) + after_each(restore_logging) + + it('logs a warn entry when a V1 contract violation is recorded', function() + local contract = v1_contract() + local observed = v1_observation_for(contract.sessionID) + local missing_directory = vim.deepcopy(contract.events.message) + missing_directory.directory = nil + + assert.is_false(v1_observation.ingest_event(observed, missing_directory)) + assert.equals('error', observed:read().sync.messages.state) + + assert.matches('%[WARN%].*V1 observation diagnostic %(messages%): .* is missing directory', read_log()) + end) + + it('logs a warn entry when a V2 boundary diagnostic is recorded', function() + local observed = v2_observation_for('ses-log-v2-diagnostic') + + assert.is_false(v2_observation.ingest_event( + observed, + v2_event('ses-log-v2-diagnostic', 'session.step.started', { + agent = 'build', + model = { providerID = 'p', id = 'm' }, + }) + )) + assert.equals('error', observed:read().sync.messages.state) + + assert.matches( + '%[WARN%].*V2 observation diagnostic %(messages%): session%.step%.started is missing assistant identity', + read_log() + ) + end) + + it('logs an unknown session event type only the first time', function() + local observed = v2_observation_for('ses-log-unknown') + + for _ = 1, 3 do + assert.is_false( + v2_observation.ingest_event(observed, v2_event('ses-log-unknown', 'session.foo.bar', { detail = 'x' })) + ) + end + + assert.equals(1, count_occurrences(read_log(), 'unhandled session event type: session%.foo%.bar')) + end) + + it('does not log unknown events belonging to other sessions', function() + local observed = v2_observation_for('ses-log-foreign') + + assert.is_false( + v2_observation.ingest_event(observed, v2_event('ses-foreign', 'session.foreign.only', { detail = 'x' })) + ) + + assert.equals(0, count_occurrences(read_log(), 'unhandled session event type: session%.foreign%.only')) + end) + + it('does not log unknown events outside the session. prefix', function() + local observed = v2_observation_for('ses-log-prefix') + + assert.is_false( + v2_observation.ingest_event(observed, v2_event('ses-log-prefix', 'other.unknown.event', { detail = 'x' })) + ) + + assert.equals(0, count_occurrences(read_log(), 'unhandled session event type: other%.unknown%.event')) + end) +end) From 31776ebd90310b1790bfca98b2d0bad55d4234a9 Mon Sep 17 00:00:00 2001 From: jensenojs Date: Fri, 16 Oct 2026 09:58:21 +0800 Subject: [PATCH 3/4] refactor(v2): join synthesized part ids instead of JSON encoding them --- lua/opencode/protocols/v2/normalize.lua | 15 +++++++- .../protocols/v2/observation/messages.lua | 4 +- tests/unit/protocol_v2_observation_spec.lua | 38 +++++++++---------- tests/unit/reference_facts_spec.lua | 16 ++++---- tests/unit/renderer_context_spec.lua | 4 +- 5 files changed, 44 insertions(+), 33 deletions(-) diff --git a/lua/opencode/protocols/v2/normalize.lua b/lua/opencode/protocols/v2/normalize.lua index 5f45fd1d7..43034a9e7 100644 --- a/lua/opencode/protocols/v2/normalize.lua +++ b/lua/opencode/protocols/v2/normalize.lua @@ -304,6 +304,16 @@ local function mapped_mention(value, text) return mention end +---Part identity synthesized by this plugin for content the server sends +---without an id. Opaque equality key: never decoded back into components. +---@param owner_id string +---@param kind string +---@param key string|integer +---@return string +local function synthetic_id(owner_id, kind, key) + return owner_id .. ':' .. kind .. ':' .. tostring(key) +end + local function mapped_file(file, prompt_text) local parsed = file_shape:parse(file, 'V2 observation: invalid file attachment') ---@cast parsed OpencodeV2ValidatedFileAttachment @@ -652,7 +662,7 @@ local function append_user_file(entry, file, index, text) ---@cast context_entry table part = context_entry end - part.id = vim.json.encode({ entry.id, 'file', index }) + part.id = synthetic_id(entry.id, 'file', index) entry.content[#entry.content + 1] = part end @@ -713,7 +723,7 @@ local function mapped_assistant(entry, info) coordinate = ordinals[part.type] ordinals[part.type] = coordinate + 1 end - content.id = vim.json.encode({ info.id, part.type, coordinate }) + content.id = synthetic_id(info.id, part.type, coordinate) entry.content[#entry.content + 1] = content end return entry @@ -916,4 +926,5 @@ return { mapped_permission = mapped_permission, enrich_permission_previews = enrich_permission_previews, mapped_question = mapped_question, + synthetic_id = synthetic_id, } diff --git a/lua/opencode/protocols/v2/observation/messages.lua b/lua/opencode/protocols/v2/observation/messages.lua index 0f24e1500..156756e56 100644 --- a/lua/opencode/protocols/v2/observation/messages.lua +++ b/lua/opencode/protocols/v2/observation/messages.lua @@ -140,7 +140,7 @@ local function ordinal_content(observation, data, event_type, create) if content or not create then return content end - content = { id = vim.json.encode({ entry.id, kind, data.ordinal }), kind = kind, text = '' } + content = { id = normalize.synthetic_id(entry.id, kind, data.ordinal), kind = kind, text = '' } entry.content[#entry.content + 1] = content index[key] = content return content @@ -172,7 +172,7 @@ local function tool_content(observation, data, event_type, create) return nil end content = { - id = vim.json.encode({ entry.id, 'tool', data.id }), + id = normalize.synthetic_id(entry.id, 'tool', data.id), kind = 'tool', call_id = data.id, name = data.name, diff --git a/tests/unit/protocol_v2_observation_spec.lua b/tests/unit/protocol_v2_observation_spec.lua index e5259b38b..e12cdfc2e 100644 --- a/tests/unit/protocol_v2_observation_spec.lua +++ b/tests/unit/protocol_v2_observation_spec.lua @@ -151,10 +151,10 @@ describe('V2 protocol Observation interpretation', function() assert.same({ created = 200, streamed = 220, completed = 240 }, reply.time) assert.same({ start = 'snap-start', ['end'] = 'snap-end', files = { 'main.lua' } }, reply.snapshot) assert.same({ input = 10, output = 5, reasoning = 2, cache = { read = 3, write = 1 } }, reply.tokens) - assert.equals('["msg-assistant","reasoning",0]', reply.content[1].id) - assert.equals('["msg-assistant","text",0]', reply.content[2].id) + assert.equals('msg-assistant:reasoning:0', reply.content[1].id) + assert.equals('msg-assistant:text:0', reply.content[2].id) local tool = reply.content[3] - assert.equals('["msg-assistant","tool","tool-1"]', tool.id) + assert.equals('msg-assistant:tool:tool-1', tool.id) assert.equals('tool-1', tool.call_id) assert.equals('completed', tool.state) assert.is_nil(tool.metadata) @@ -541,10 +541,10 @@ describe('V2 protocol Observation interpretation', function() assert.equals('AR', entry.content[1].text) assert.equals('BT', entry.content[2].text) assert.equals('C', entry.content[3].text) - assert.equals('["msg-live","reasoning",0]', entry.content[1].id) - assert.equals('["msg-live","text",0]', entry.content[2].id) - assert.equals('["msg-live","reasoning",1]', entry.content[3].id) - assert.equals('["msg-live","tool","tool-live"]', entry.content[4].id) + assert.equals('msg-live:reasoning:0', entry.content[1].id) + assert.equals('msg-live:text:0', entry.content[2].id) + assert.equals('msg-live:reasoning:1', entry.content[3].id) + assert.equals('msg-live:tool:tool-live', entry.content[4].id) assert.equals('tool-live', entry.content[4].call_id) assert.equals('completed', entry.content[4].state) assert.is_nil(entry.content[4].metadata) @@ -683,10 +683,10 @@ describe('V2 protocol Observation interpretation', function() ) assert.same( { - '["msg-mix","text",0]', - '["msg-mix","tool","tool-mix"]', - '["msg-mix","reasoning",0]', - '["msg-mix","text",1]', + 'msg-mix:text:0', + 'msg-mix:tool:tool-mix', + 'msg-mix:reasoning:0', + 'msg-mix:text:1', }, vim.tbl_map(function(content) return content.id @@ -718,10 +718,10 @@ describe('V2 protocol Observation interpretation', function() local replaced = observed:read().entries_by_id['msg-mix'] assert.same( { - '["msg-mix","text",0]', - '["msg-mix","tool","tool-mix"]', - '["msg-mix","reasoning",0]', - '["msg-mix","text",1]', + 'msg-mix:text:0', + 'msg-mix:tool:tool-mix', + 'msg-mix:reasoning:0', + 'msg-mix:text:1', }, vim.tbl_map(function(content) return content.id @@ -741,7 +741,7 @@ describe('V2 protocol Observation interpretation', function() ) ) assert.equals('T0-final', replaced.content[1].text) - assert.equals('["msg-mix","text",0]', replaced.content[1].id) + assert.equals('msg-mix:text:0', replaced.content[1].id) assert.equals('snap-t1', replaced.content[4].text) assert.is_true(observation_module.ingest_event( @@ -762,7 +762,7 @@ describe('V2 protocol Observation interpretation', function() content = { { type = 'text', text = 'done' } }, }) )) - assert.equals('["msg-mix","tool","tool-mix"]', replaced.content[2].id) + assert.equals('msg-mix:tool:tool-mix', replaced.content[2].id) assert.equals('tool-mix', replaced.content[2].call_id) assert.equals('completed', replaced.content[2].state) end) @@ -807,11 +807,11 @@ describe('V2 protocol editor-context attachments', function() local context_entry = entry.content[2] assert.same('editor_context', context_entry.kind) assert.is_true(context_entry.synthetic) - assert.equals('["msg-user","file",1]', context_entry.id) + assert.equals('msg-user:file:1', context_entry.id) assert.same('selection', context_entry.source.kind) assert.same('test.py', context_entry.source.file_name) assert.same('1-2', context_entry.source.range) assert.same('selected code', context_entry.text) - assert.equals('["msg-user","file",2]', entry.content[3].id) + assert.equals('msg-user:file:2', entry.content[3].id) end) end) diff --git a/tests/unit/reference_facts_spec.lua b/tests/unit/reference_facts_spec.lua index aeb37b5ae..094c529d4 100644 --- a/tests/unit/reference_facts_spec.lua +++ b/tests/unit/reference_facts_spec.lua @@ -138,11 +138,11 @@ describe('opencode.ui.reference_facts', function() session_id = 'ses_1', content = { { kind = 'text', text = 'look' }, - { id = vim.json.encode({ 'user_1', 'file', 1 }), kind = 'file', name = 'src/ok.lua' }, + { id = 'user_1:file:1', kind = 'file', name = 'src/ok.lua' }, }, }, assistant_message('msg_1', 'ses_1', { - { id = vim.json.encode({ 'msg_1', 'text', 0 }), kind = 'text', text = 'See `src/ok.lua`.' }, + { id = 'msg_1:text:0', kind = 'text', text = 'See `src/ok.lua`.' }, { kind = 'agent', name = 'review' }, { kind = 'skill', skill_id = 's', name = 'review', text = 'x' }, }), @@ -152,19 +152,19 @@ describe('opencode.ui.reference_facts', function() assert.equal(2, #refs) assert.equal('user_file_part', refs[1].source_kind) - assert.equal('["user_1","file",1]', refs[1].part_id) + assert.equal('user_1:file:1', refs[1].part_id) assert.equal('assistant_text', refs[2].source_kind) - assert.equal('["msg_1","text",0]', refs[2].part_id) + assert.equal('msg_1:text:0', refs[2].part_id) end) it('isolates identical raw tool ids across messages under synthesized identities', function() rebuild({ assistant_message('msg_a', 'ses_1', { - { id = vim.json.encode({ 'msg_a', 'tool', 'shared' }), kind = 'tool', target = { path = '/repo/src/ok.lua' } }, + { id = 'msg_a:tool:shared', kind = 'tool', target = { path = '/repo/src/ok.lua' } }, }), assistant_message('msg_b', 'ses_1', { { - id = vim.json.encode({ 'msg_b', 'tool', 'shared' }), + id = 'msg_b:tool:shared', kind = 'tool', target = { path = '/repo/src/tool.lua' }, }, @@ -175,10 +175,10 @@ describe('opencode.ui.reference_facts', function() assert.equal(2, #refs) assert.equal('msg_a', refs[1].message_id) - assert.equal('["msg_a","tool","shared"]', refs[1].part_id) + assert.equal('msg_a:tool:shared', refs[1].part_id) assert.equal('src/ok.lua', refs[1].path) assert.equal('msg_b', refs[2].message_id) - assert.equal('["msg_b","tool","shared"]', refs[2].part_id) + assert.equal('msg_b:tool:shared', refs[2].part_id) assert.equal('src/tool.lua', refs[2].path) end) diff --git a/tests/unit/renderer_context_spec.lua b/tests/unit/renderer_context_spec.lua index 2bfc2d542..06af132b2 100644 --- a/tests/unit/renderer_context_spec.lua +++ b/tests/unit/renderer_context_spec.lua @@ -261,12 +261,12 @@ describe('renderer content keys', function() local entry = { id = 'msg-a', content = { - { id = vim.json.encode({ 'msg-a', 'text', 0 }), kind = 'text', text = '' }, + { id = 'msg-a:text:0', kind = 'text', text = '' }, { kind = 'text', text = 'user text' }, }, } local instance = contexts.new() - assert.equals('["msg-a","text",0]', instance.content_key(entry, 1)) + assert.equals('msg-a:text:0', instance.content_key(entry, 1)) assert.equals('msg-a:content:2', instance.content_key(entry, 2)) end) end) From f402ae4610cba6df02b78c0cafe0636e12c1c955 Mon Sep 17 00:00:00 2001 From: jensenojs Date: Fri, 16 Oct 2026 10:00:47 +0800 Subject: [PATCH 4/4] chore(v2): require part and message in text reference matching --- lua/opencode/ui/formatter.lua | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lua/opencode/ui/formatter.lua b/lua/opencode/ui/formatter.lua index c0b74846f..0cc96dcc8 100644 --- a/lua/opencode/ui/formatter.lua +++ b/lua/opencode/ui/formatter.lua @@ -664,13 +664,12 @@ local function current_part_text_references(part, message, text, context) end local trim_offset = part_text_trim_offset(part, text) - local message_id = message and message.id local refs = {} for _, ref in ipairs(context.current_refs) do local raw_range = ref.raw_range if ref.source_kind == 'assistant_text' - and ref.message_id == message_id + and ref.message_id == message.id and ref.part_id == part.id and raw_range then @@ -845,8 +844,8 @@ end ---@param output Output Output object to write to ---@param text string ----@param part? table ----@param message? table +---@param part table +---@param message table ---@param context? FormatterContext function M._format_assistant_message(output, text, part, message, context) local references = current_part_text_references(part, message, text, context)