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: 2 additions & 0 deletions lua/opencode/protocols/v1/observation.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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)
Expand Down
53 changes: 37 additions & 16 deletions lua/opencode/protocols/v2/normalize.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -305,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
Expand Down Expand Up @@ -543,7 +552,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,
Expand Down Expand Up @@ -640,20 +648,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 = synthetic_id(entry.id, 'file', index)
entry.content[#entry.content + 1] = part
end

local function mapped_user(entry, info)
Expand Down Expand Up @@ -703,8 +713,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 = synthetic_id(info.id, part.type, coordinate)
entry.content[#entry.content + 1] = content
end
return entry
end
Expand Down Expand Up @@ -906,4 +926,5 @@ return {
mapped_permission = mapped_permission,
enrich_permission_previews = enrich_permission_previews,
mapped_question = mapped_question,
synthetic_id = synthetic_id,
}
2 changes: 2 additions & 0 deletions lua/opencode/protocols/v2/observation/boundary.lua
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
local lifecycle = require('opencode.protocols.observation')
local log = require('opencode.log')

local M = {}

Expand All @@ -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

Expand Down
32 changes: 27 additions & 5 deletions lua/opencode/protocols/v2/observation/messages.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}

Expand All @@ -22,8 +23,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
Expand Down Expand Up @@ -139,7 +140,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 = normalize.synthetic_id(entry.id, kind, data.ordinal), kind = kind, text = '' }
entry.content[#entry.content + 1] = content
index[key] = content
return content
Expand Down Expand Up @@ -170,7 +171,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 = normalize.synthetic_id(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
Expand Down Expand Up @@ -390,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)
Expand Down
5 changes: 3 additions & 2 deletions lua/opencode/services/session_runtime.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
6 changes: 3 additions & 3 deletions lua/opencode/ui/formatter.lua
Original file line number Diff line number Diff line change
Expand Up @@ -659,7 +659,7 @@ 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

Expand Down Expand Up @@ -844,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)
Expand Down
13 changes: 8 additions & 5 deletions lua/opencode/ui/navigation.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
9 changes: 1 addition & 8 deletions lua/opencode/ui/reference_facts.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand Down
Loading
Loading