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
31 changes: 31 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,37 @@ jobs:
if: steps.check.outputs.status != '0'
run: exit 1

# See tests/unit/protocol_v2_event_coverage_spec.lua
event-contract:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4

- name: setup neovim
uses: rhysd/action-setup-vim@v1
with:
neovim: true
version: v0.11.4

- name: Download opencode
run: |
VERSION=$(curl -fsSL "https://registry.npmjs.org/@opencode%2fcli-linux-x64" | jq -r '.["dist-tags"]["latest"]')
if [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then
echo "::error::could not resolve latest @opencode/cli-linux-x64 version"
exit 1
fi
echo "opencode $VERSION (@opencode/cli-linux-x64)"
curl -fsSL "https://registry.npmjs.org/@opencode/cli-linux-x64/-/cli-linux-x64-$VERSION.tgz" -o /tmp/opencode.tgz
mkdir -p /tmp/opencode-unpack
tar -xzf /tmp/opencode.tgz -C /tmp/opencode-unpack
sudo install -m 0755 /tmp/opencode-unpack/package/bin/opencode /usr/local/bin/opencode

- name: Run event contract coverage
env:
OPENCODE_EVENTS_SOURCE: /usr/local/bin/opencode
run: ./run_tests.sh -t tests/unit/protocol_v2_event_coverage_spec.lua

test:
timeout-minutes: 4
strategy:
Expand Down
144 changes: 144 additions & 0 deletions lua/opencode/protocols/v2/event_contract.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
---Adjudication record for the upstream V2 event contract. The source of truth
---for which `session.*` events exist is the opencode distribution itself,
---extracted at check time; never snapshotted here.

local log = require('opencode.log')

local M = {}

---Event types with no handler on purpose; each reason says why the missing
---projection has no user-visible consequence.
M.EXEMPT = {
['session.agent.selected'] = 'the server persists an agent-switched message; the snapshot channel carries it and rendering hides these messages anyway',
['session.model.selected'] = 'the server persists a model-switched message; the snapshot channel carries it and rendering hides these messages anyway',
['session.viewed'] = 'read receipt for other clients; nothing in the UI consumes it',
['session.archived'] = 'the session list is refetched whenever it is shown, so an archived session drops out on the next fetch; an already open tab keeps its copy until the user closes it',
['session.unarchived'] = 'the session list is refetched whenever it is shown, so an unarchived session reappears on the next fetch',
['session.permissions'] = 'permission requests arrive through the permissions resource pull; this event only updates the ruleset, which is static in practice',
['session.forked'] = 'forking creates a new session the user switches to explicitly; no incremental dependency',
['session.usage.recorded'] = 'per-call usage detail; the UI consumes the session.usage.updated aggregate instead',
['session.metadata.updated'] = 'server-side session metadata; nvim picks up title etc. on the next session fetch',
['session.shell.started'] = 'shell messages reach the snapshot; the projection would only advance visibility until the next refetch',
['session.shell.ended'] = 'shell messages reach the snapshot; the projection would only advance visibility until the next refetch',
['session.skill.activated'] = 'the server persists the activation notice; the plugin has no skill-state UI',
['session.compaction.delta'] = 'streaming compaction progress display only; session.compaction.ended (handled) triggers the refetch that carries the result',
['session.status'] = 'ephemeral busy/idle/retry broadcast; the durable execution lifecycle events plus session.retry.scheduled already carry these signals',
['session.idle'] = 'ephemeral viewer read-state watermark managed server-side; no UI consumption in the plugin',
['session.diff'] = 'ephemeral TUI diff-panel refresh signal; the plugin pulls diff data through the API on demand',
['session.updated'] = 'v1-protocol session info event still shipped in the binary; the v2 observation consumes v2 events only',
}

---Handled-worthy event types with no handler yet; unhandled events surface in
---the runtime log.
M.KNOWN_GAPS = {
['session.message.content.updated'] = 'edited message content does not re-render until the next refetch',
['session.compaction.started'] = 'no compaction-in-progress indication while it runs',
['session.compaction.failed'] = 'compaction failure is invisible to the user',
['session.error'] = 'ephemeral session-level error broadcast; step failures surface through the handled execution events, but error shapes outside a step would be invisible',
}

---@param source_path string path to the opencode schema source: a compiled binary/JS
---file, or a directory holding the *.ts schema (event definitions live across
---sibling files, so a single file is not the full union)
---@return string[] event_types sorted `session.*` event type literals found in the source
function M.extract_session_events(source_path)
-- The [a-z.] identifier charset matches the upstream naming convention;
-- event names with digits or uppercase would be silently missed here.
local chunks = {}
if vim.fn.isdirectory(source_path) == 1 then
for _, file_path in ipairs(vim.fn.globpath(source_path, '**/*.ts', false, true)) do
local file = io.open(file_path, 'r')
if file then
chunks[#chunks + 1] = file:read('*a')
file:close()
end
end
else
local file = io.open(source_path, 'r')
if not file then
error('cannot open opencode schema source: ' .. source_path, 0)
end
chunks[#chunks + 1] = file:read('*a')
file:close()
end
local source = table.concat(chunks, '\n')

-- Anchor on the schema shape (`type: "session.xxx"`), not on the bare string:
-- the compiled distribution also contains TUI command names like
-- `session.copy.id` that are not event types.
local found = {}
for event_type in source:gmatch('[\'"]?type[\'"]?%s*:%s*[\'"](session%.[a-z.]+)[\'"]') do
found[event_type] = true
end

local types = {}
for event_type in pairs(found) do
types[#types + 1] = event_type
end
table.sort(types)
return types
end

local notified_unknown = {}

---Report an event type we neither handle nor exempt, once per type: a new gap
---should be discoverable without flooding the log.
---@param event_type string
function M.note_unknown(event_type)
if notified_unknown[event_type] then
return
end
notified_unknown[event_type] = true
local gap = M.KNOWN_GAPS[event_type]
if gap then
log.warn('unhandled v2 event %s (known gap: %s)', event_type, gap)
return
end
log.warn(
'unhandled v2 event %s is not in the contract; adjudicate it in lua/opencode/protocols/v2/event_contract.lua',
event_type
)
end

---@param handled table<string, boolean> event types with a registered handler
---@param upstream_events string[] event types extracted from the opencode distribution
---@return string[] unknown_in_source handler keys that are not upstream session events
---@return string[] unadjudicated upstream events that are neither handled, exempt, nor a known gap
---@return string[] stale_adjudications exempt/gap entries whose event no longer exists upstream
function M.check(handled, upstream_events)
local upstream = {}
for _, event_type in ipairs(upstream_events) do
upstream[event_type] = true
end

local unknown_in_source = {}
for event_type in pairs(handled) do
-- Other event families (form.*, filesystem.*) are out of contract scope.
if event_type:match('^session%.') and not upstream[event_type] then
table.insert(unknown_in_source, event_type)
end
end
table.sort(unknown_in_source)

local unadjudicated = {}
for _, event_type in ipairs(upstream_events) do
if not handled[event_type] and not M.EXEMPT[event_type] and not M.KNOWN_GAPS[event_type] then
table.insert(unadjudicated, event_type)
end
end
table.sort(unadjudicated)

local stale_adjudications = {}
for _, table_ in ipairs({ M.EXEMPT, M.KNOWN_GAPS }) do
for event_type in pairs(table_) do
if not upstream[event_type] then
table.insert(stale_adjudications, event_type)
end
end
end
table.sort(stale_adjudications)

return unknown_in_source, unadjudicated, stale_adjudications
end

return M
24 changes: 24 additions & 0 deletions lua/opencode/protocols/v2/observation/events.lua
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,25 @@ local function apply_event(observation, event, route)
return true
end

local handled_types_cache

---Every session event type the observation layer dispatches: the route table
---plus the message resource's own handlers.
---@return table<string, boolean>
function M.handled_types()
if handled_types_cache then
return handled_types_cache
end
handled_types_cache = {}
for event_type in pairs(routes) do
handled_types_cache[event_type] = true
end
for event_type in pairs(messages.handler_types()) do
handled_types_cache[event_type] = true
end
return handled_types_cache
end

---@param connection OpencodeV2Connection
---@param event OpencodeV2Event
function M.route(connection, event)
Expand Down Expand Up @@ -431,6 +450,11 @@ function M.route(connection, event)
changed[resource] = true
end
end
elseif local_event and event.type:match('^session%.') then
local contract = require('opencode.protocols.v2.event_contract')
if not M.handled_types()[event.type] and not contract.EXEMPT[event.type] then
contract.note_unknown(event.type)
end
end

if normalize.enrich_permission_previews(observation:read()) then
Expand Down
111 changes: 81 additions & 30 deletions lua/opencode/protocols/v2/observation/messages.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
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 Down Expand Up @@ -186,6 +185,12 @@
---@type table<string, fun(observation: OpencodeV2Observation, event: OpencodeV2Event, data: table): boolean>
local handlers = {}

---Signals that the cached page is out of date and must be refetched.
---@return 'refetch'
local function invalidate_messages()
return 'refetch'
end

---@param observation OpencodeV2Observation
---@param event OpencodeV2Event
---@param data table
Expand Down Expand Up @@ -216,6 +221,75 @@
return true
end

---Server-side notices (restart, instruction changes) are projected into messages
---by the backend, but the message snapshot can predate their arrival and the
---snapshot itself is not re-pulled on ordinary traffic, so without these handlers
---the notices never reach the observation. Mirrors the TUI's projection in
---message-updater.ts.
local function put_notice(observation, event, info)

Check notice on line 229 in lua/opencode/protocols/v2/observation/messages.lua

View workflow job for this annotation

GitHub Actions / typecheck

event is never used, if this is intentional, prefix it with an underscore: _event
local ok, entry = pcall(normalize.mapped_message, observation._session_id, info)
if not ok then
return boundary.diagnostic(observation, 'messages', tostring(entry))
end
---@cast entry table
put_entry(observation, entry)
return true
end

local function notice_message_id(event)
-- SessionMessage.ID.fromEvent: the message id is the durable event id with the
-- evt_ prefix swapped, so the projected entry merges with the snapshot copy.
return (event.id:gsub('^evt_', 'msg_'))
end

---@param observation OpencodeV2Observation
---@param event OpencodeV2Event
---@param data table
handlers['session.synthetic'] = function(observation, event, data)
local metadata = data.metadata
-- Background completions carry no message id, so the snapshot is the only
-- source of the entry that records their outcome.
if normalize.is_background_notification(metadata) then
return invalidate_messages()
end
-- Only server-authored notices carry a description; plain synthetic parts are
-- already in the message snapshot, so projecting them again would duplicate.
if type(data.description) ~= 'string' or data.description == '' then
return false
end
if type(data.text) ~= 'string' then
return invalid(observation, event.type, 'is missing text')
end
return put_notice(observation, event, {
id = notice_message_id(event),
type = 'synthetic',
text = data.text,
description = data.description,
time = { created = event.created },
})
end

---@param observation OpencodeV2Observation
---@param event OpencodeV2Event
---@param data table
handlers['session.instructions.updated'] = function(observation, event, data)
if data.text == nil then
return false
end
if type(data.text) ~= 'string' or type(data.delta) ~= 'table' then
return invalid(observation, event.type, 'has invalid text/delta')
end
local keys = vim.tbl_keys(data.delta)
table.sort(keys)
return put_notice(observation, event, {
id = notice_message_id(event),
type = 'system',
text = data.text,
description = 'Instructions updated: ' .. table.concat(keys, ', '),
time = { created = event.created },
})
end

---@param observation OpencodeV2Observation
---@param event OpencodeV2Event
---@param data table
Expand Down Expand Up @@ -399,44 +473,15 @@
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

---Background completions arrive as synthetic messages without a message id, so the
---projected snapshot is the only source of the entry that carries the job outcome.
---@param observation OpencodeV2Observation
---@param event OpencodeV2Event
---@param data table
---@return string|boolean
handlers['session.synthetic'] = function(observation, event, data)
if not normalize.is_background_notification(data.metadata) then
return false
end
return invalidate_messages()
end

-- 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 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
Expand Down Expand Up @@ -539,6 +584,12 @@
end
end

---Session event types the message resource handles.
---@return table<string, fun(observation: OpencodeV2Observation, event: OpencodeV2Event, data: table): boolean>
function M.handler_types()
return handlers
end

---@param observation OpencodeV2Observation
function M.release(observation)
observation._v2_content_by_message = {}
Expand Down
2 changes: 1 addition & 1 deletion lua/opencode/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,7 @@
---@field path string
---@field line? integer
---@field col? integer
---@field source_kind 'assistant_text'|'tool_file_path'
---@field source_kind 'assistant_text'|'tool_file_path'|'user_file_part'|'editor_context_selection'
---@field raw_range? CodeReferenceTextRange Required for assistant_text references
---@field order integer Smaller values appear earlier in the session message/part/text order.

Expand Down
Loading
Loading