diff --git a/README.md b/README.md index 2abb232c..ff2936d2 100644 --- a/README.md +++ b/README.md @@ -1422,7 +1422,7 @@ Run development commands through the Makefile from the repository root. Use `mak | `make test` | Run all tests | | `make test-minimal` | Run minimal tests | | `make test-unit` | Run unit tests | -| `make test-replay` | Run automated replay tests | +| `make test-replay` | Run V1/V2 JSON snapshot replays only, with each fixture named in the output | | `make replay` | Launch the interactive replay tester | | `make replay-regenerate` | Regenerate expected replay snapshots with confirmation | | `make topology` | Scan dependency topology | diff --git a/lua/opencode/event_capture.lua b/lua/opencode/event_capture.lua new file mode 100644 index 00000000..dbc16249 --- /dev/null +++ b/lua/opencode/event_capture.lua @@ -0,0 +1,50 @@ +---Central store for streamed server events, used for replay fixtures. +---Protocol adapters record protocol-specific event shapes here when +---`debug.capture_streamed_events` is enabled. Foundation module so Entry +---(debug_helper) and Infrastructure (protocols) can share it without a layer violation. +---@class OpencodeEventCapture +---@field events table[] Captured protocol-tagged events for debugging +local M = { + events = {}, +} + +---Record one protocol-specific event when capture is enabled. +---@param event {type: string, protocol?: 'v1'|'v2', properties?: table, data?: table, created?: number, id?: string} +function M.record(event) + local ok, config = pcall(require, 'opencode.config') + if not ok or not config.debug or not config.debug.capture_streamed_events then + return + end + if type(event) ~= 'table' or type(event.type) ~= 'string' then + return + end + table.insert(M.events, vim.deepcopy(event)) +end + +---Borrow the captured events. +---@return table[] +function M.get() + return M.events +end + +---Discard all captured events. +function M.clear() + M.events = {} +end + +---Write captured events to a JSON file. +---@param filename? string Destination path, defaults to `data.json` +---@return integer|nil count Number of events saved, or nil when nothing was saved +function M.save(filename) + filename = filename or 'data.json' + if #M.events == 0 then + vim.notify('No captured events to save', vim.log.levels.WARN) + return nil + end + local json_str = vim.json.encode(M.events) + vim.fn.writefile(vim.split(json_str, '\n'), filename) + vim.notify(string.format('Saved %d events to %s', #M.events, filename), vim.log.levels.INFO) + return #M.events +end + +return M diff --git a/lua/opencode/protocols/observation.lua b/lua/opencode/protocols/observation.lua index d48b2b82..c6b3927e 100644 --- a/lua/opencode/protocols/observation.lua +++ b/lua/opencode/protocols/observation.lua @@ -21,6 +21,14 @@ local resource_names = { ---@field id string Stable message identifier ---@field kind string Message kind, such as user or assistant ---@field content table[] Message content parts +---@field queued? boolean User input awaiting assistant consumption, projected by the protocol adapter + +---@class OpencodePermissionPreview +---@field title? string Request title, independent of tool description +---@field command? string Exact command requiring approval +---@field diff? string Unified diff requiring approval +---@field path? string Target file path +---@field source? {message_id: string, call_id: string} Linked tool supplying preview details ---@class OpencodeObservationState ---@field session OpencodeSession @@ -30,7 +38,7 @@ local resource_names = { ---@field entry_order string[] ---@field inbox {items_by_id: table, order: string[]} ---@field execution {activity: string, last_outcome?: string, last_idle?: number, retry?: table, error?: table} ----@field permission_requests_by_id table +---@field permission_requests_by_id table ---@field question_requests_by_id table ---@field files {revision: integer, path?: string, change?: string, last?: {path: string, event: string}} @@ -75,7 +83,8 @@ end --- Decode an editor-context payload (selection / diagnostics / cursor-data / --- file-content / git-diff) into the protocol-neutral contract entry. --- Both protocol adapters map their wire shapes onto this one: V1 carries it ---- as a synthetic text part with metadata.context_type, V2 as a file +--- as a synthetic text part with metadata.context_type (or a validated legacy +--- JSON context_type declaration), V2 as a file --- attachment whose name is prefixed with "editor-context:". --- @param context_type string the wire-declared context type --- @param text string JSON payload for selection/diagnostics/cursor-data, diff --git a/lua/opencode/protocols/v1/normalize.lua b/lua/opencode/protocols/v1/normalize.lua index 3fa47a8e..6cc74746 100644 --- a/lua/opencode/protocols/v1/normalize.lua +++ b/lua/opencode/protocols/v1/normalize.lua @@ -75,15 +75,7 @@ local file_source_shape = v.union( end) ) -local session_shape = v.object({ - id = 'string', - slug = 'string', - projectID = 'string', - directory = 'string', - title = 'string', - version = 'string', - time = { created = 'number', updated = 'number' }, -}):convert(function(info) +local function mapped_session_info(info) return { id = info.id, title = info.title, @@ -100,7 +92,47 @@ local session_shape = v.object({ share = vim.deepcopy(info.share), revert = vim.deepcopy(info.revert), } -end) +end + +local session_shape = v.object({ + id = 'string', + slug = 'string', + projectID = 'string', + directory = 'string', + title = 'string', + version = 'string', + time = { created = 'number', updated = 'number' }, +}):convert(mapped_session_info) + +local legacy_revert_shape = v.object({ + messageID = 'string', + partID = v.string():optional(), + snapshot = v.string():optional(), + diff = v.string():optional(), +}) + +local legacy_revert_session_shape = v.object({ + id = 'string', + projectID = 'string', + directory = 'string', + title = 'string', + version = v.string():constraint(function(version) + return version:match('^0%.%d+%.%d+') ~= nil + end, 'legacy V1 version'), + time = { created = 'number', updated = 'number' }, + revert = legacy_revert_shape, +}) + :constraint(function(info) + return info.slug == nil + end, 'legacy V1 session without slug') + :convert(mapped_session_info) + +local permission_metadata_shape = v.object({ + command = v.string():optional(), + diff = v.string():optional(), + filePath = v.string():optional(), + filepath = v.string():optional(), +}) local permission_shape = v.object({ id = 'string', @@ -126,6 +158,51 @@ local permission_shape = v.object({ } end) +local legacy_permission_shape = v.object({ + id = 'string', + sessionID = 'string', + type = 'string', + title = 'string', + messageID = 'string', + callID = 'string', + time = v.object({ created = 'number' }), + pattern = v.union(v.string(), v.array('string')):optional(), + metadata = permission_metadata_shape, +}):convert(function(request) + local patterns = request.pattern or {} + if type(patterns) == 'string' then + patterns = { patterns } + end + local mapped = permission_shape:parse({ + id = request.id, + sessionID = request.sessionID, + permission = request.type, + patterns = patterns, + always = {}, + metadata = request.metadata, + tool = { messageID = request.messageID, callID = request.callID }, + }) + mapped.preview = { + title = request.title, + command = request.metadata.command, + diff = request.metadata.diff, + path = request.metadata.filePath or request.metadata.filepath, + source = { message_id = request.messageID, call_id = request.callID }, + } + return mapped +end) + +local permission_reply_shape = v.union( + v.object({ sessionID = 'string', requestID = 'string', reply = v.enum({ 'once', 'always', 'reject' }) }) + :convert(function(reply) + return { session_id = reply.sessionID, request_id = reply.requestID, reply = reply.reply } + end), + v.object({ sessionID = 'string', permissionID = 'string', response = v.enum({ 'once', 'always', 'reject' }) }) + :convert(function(reply) + return { session_id = reply.sessionID, request_id = reply.permissionID, reply = reply.response } + end) +) + local question_shape = v.object({ id = 'string', sessionID = 'string', @@ -181,11 +258,53 @@ local function mapped_content_time(value) return content_time_shape:parse(value, 'V1 observation: invalid content time') end +local legacy_synthetic_context_shape = v.object({ synthetic = v.literal(true), text = 'string' }) +local legacy_context_marker_shape = v.object({ + context_type = v.enum({ 'selection', 'diagnostics', 'cursor-data', 'review-comment' }), +}) +local legacy_diagnostics_shape = v.object({ + context_type = v.literal('diagnostics'), + content = v.array(v.union( + v.object({ msg = 'string', severity = v.enum({ 1, 2, 3, 4 }), pos = 'string' }), + v.object({ + message = 'string', + severity = v.enum({ 1, 2, 3, 4 }), + lnum = v.integer():min(0), + col = v.integer():min(0), + }):convert(function(value) + return { + msg = value.message, + severity = value.severity, + pos = string.format('l%d:c%d', value.lnum + 1, value.col + 1), + } + end) + )), +}) + +local function legacy_context_content(part) + if not legacy_synthetic_context_shape:is(part) then + return nil + end + local ok, decoded = pcall(vim.json.decode, part.text) + if not ok or not legacy_context_marker_shape:is(decoded) then + return nil + end + local text = part.text + if decoded.context_type == 'diagnostics' then + if not legacy_diagnostics_shape:is(decoded) then + return nil, 'invalid diagnostics editor context' + end + -- Historical captures stored raw Neovim diagnostics with zero-based positions. + text = vim.json.encode(legacy_diagnostics_shape:parse(decoded)) + end + return shared_decode_editor_context(decoded.context_type, text, part.id, part.synthetic, part.ignored) +end + local function context_content(part) local metadata = part.metadata local context_type = type(metadata) == 'table' and metadata.context_type or nil if context_type == nil then - return nil + return legacy_context_content(part) end if context_type == 'file-content' and type(metadata.mime) == 'string' then -- V1 carries the buffer media type in part metadata @@ -604,17 +723,54 @@ local function mapped_message(message, location) end ---@param info table ----@return table +---@return table session +---@return boolean legacy_revert local function mapped_session(info) - return session_shape:parse(info, 'V1 observation: invalid session info') + local current_ok, session = pcall(session_shape.parse, session_shape, info) + if current_ok then + ---@cast session table + return session, false + end + return legacy_revert_session_shape:parse(info, 'V1 observation: invalid session info'), true end ---@param request table ---@return table local function mapped_permission(request) + if request.permission == nil and request.type ~= nil then + return legacy_permission_shape:parse(request, 'V1 observation: invalid legacy permission request') + end return permission_shape:parse(request, 'V1 observation: invalid permission request') end +---Only validated, linked tool input can enrich the frozen permission preview. +---@param state OpencodeObservationState +---@return boolean +local function enrich_permission_previews(state) + local changed = false + for _, request in pairs(state.permission_requests_by_id) do + local preview = request.preview + local source = preview and preview.source + local entry = source and state.entries_by_id[source.message_id] + if request.status == 'pending' and preview and source and entry then + for _, content in ipairs(entry.content) do + ---@cast content {kind: string, call_id?: string, description?: string, command?: string} + if content.kind == 'tool' and content.call_id == source.call_id then + if content.description and content.description ~= '' and request.message ~= content.description then + request.message = content.description + changed = true + end + if content.command and content.command ~= '' and preview.command ~= content.command then + preview.command = content.command + changed = true + end + end + end + end + end + return changed +end + ---@param request table ---@return table local function mapped_question(request) @@ -656,5 +812,15 @@ return { mapped_message = mapped_message, mapped_session = mapped_session, mapped_permission = mapped_permission, + mapped_legacy_permission = function(request) + return legacy_permission_shape:parse(request, 'V1 observation: invalid legacy permission request') + end, + mapped_current_permission = function(request) + return permission_shape:parse(request, 'V1 observation: invalid permission request') + end, + mapped_permission_reply = function(reply) + return permission_reply_shape:parse(reply, 'permission.replied is missing request identity or has invalid reply') + end, + enrich_permission_previews = enrich_permission_previews, mapped_question = mapped_question, } diff --git a/lua/opencode/protocols/v1/observation.lua b/lua/opencode/protocols/v1/observation.lua index 905ab738..c8f30b0e 100644 --- a/lua/opencode/protocols/v1/observation.lua +++ b/lua/opencode/protocols/v1/observation.lua @@ -17,6 +17,7 @@ local id = require('opencode.id') local Promise = require('opencode.promise') local util = require('opencode.util') local config_file = require('opencode.config_file') +local event_capture = require('opencode.event_capture') local M = {} @@ -109,8 +110,12 @@ local function route_event(connection, event) observation:_event_changed(resource) end end + if normalize.enrich_permission_previews(observation:read()) then + observation:_event_changed('permissions') + end end if decoded then + event_capture.record({ protocol = 'v1', type = decoded.type, properties = decoded.properties }) vim.api.nvim_exec_autocmds('User', { pattern = 'OpencodeEvent:' .. decoded.type, data = { event = { type = decoded.type, properties = decoded.properties } }, @@ -235,11 +240,20 @@ function M.ingest_snapshot(observation, messages) if existing then entry.cost = entry.cost ~= nil and entry.cost or existing.cost entry.tokens = entry.tokens ~= nil and entry.tokens or existing.tokens + entry.queued = existing.queued end entries_by_id[entry.id] = replace_entry(existing, entry) order[#order + 1] = entry.id end state.entries_by_id, state.entry_order = entries_by_id, order + for _, entry in ipairs(mapped) do + if entry.kind == 'assistant' and entry.parent_message_id then + local parent = entries_by_id[entry.parent_message_id] + if parent then + parent.queued = nil + end + end + end observation._v1_unresolved_mentions = {} state.sync.messages = #diagnostics == 0 and { state = 'current' } or { state = 'error', error = { kind = 'protocol_contract', message = table.concat(diagnostics, '; ') } } @@ -314,6 +328,15 @@ function M.ingest_event(observation, event) if existing then entry.cost = entry.cost ~= nil and entry.cost or existing.cost entry.tokens = entry.tokens ~= nil and entry.tokens or existing.tokens + entry.queued = existing.queued + elseif entry.kind == 'user' then + entry.queued = state.execution.activity == 'running' or state.execution.activity == 'retrying' + end + if entry.kind == 'assistant' and entry.parent_message_id then + local parent = state.entries_by_id[entry.parent_message_id] + if parent then + parent.queued = nil + end end state.entries_by_id[entry.id] = replace_entry(existing, entry) if not existing then @@ -495,6 +518,7 @@ local function apply_resource(observation, resource, value) else fail('unsupported resource read: ' .. tostring(resource)) end + normalize.enrich_permission_previews(state) end local function event_diagnostic(observation, resource, message) @@ -559,7 +583,7 @@ ingest_resource_event = function(observation, event) end if kind == 'session.created' or kind == 'session.updated' then - local ok, session = pcall(mapped_session, properties.info) + local ok, session, legacy_revert = pcall(mapped_session, properties.info) if not ok then if observation:_watches('session') or observation:_watches('children') then return event_diagnostic( @@ -570,7 +594,7 @@ ingest_resource_event = function(observation, event) end return nil end - if type(properties.sessionID) ~= 'string' or properties.sessionID ~= session.id then + if properties.sessionID ~= session.id and not (legacy_revert and properties.sessionID == nil) then return event_diagnostic( observation, observation:_watches('session') and 'session' or 'children', @@ -641,11 +665,13 @@ ingest_resource_event = function(observation, event) end state.sync.execution = { state = 'current' } return 'execution' - elseif kind == 'permission.asked' then + elseif kind == 'permission.asked' or kind == 'permission.updated' then if not observation:_watches('permissions') then return nil end - local ok, request = pcall(mapped_permission, properties) + local mapper = kind == 'permission.updated' and normalize.mapped_legacy_permission + or normalize.mapped_current_permission + local ok, request = pcall(mapper, properties) if not ok then return event_diagnostic(observation, 'permissions', tostring(request)) end @@ -664,17 +690,18 @@ ingest_resource_event = function(observation, event) if not observation:_watches('permissions') then return nil end - if type(properties.sessionID) ~= 'string' or type(properties.requestID) ~= 'string' then - return event_diagnostic(observation, 'permissions', 'permission.replied is missing request identity') + local ok, reply = pcall(normalize.mapped_permission_reply, properties) + if not ok then + return event_diagnostic(observation, 'permissions', tostring(reply)) end - if properties.sessionID ~= state.session.id then + if reply.session_id ~= state.session.id then return nil end - observation._v1_permission_terminal[properties.requestID] = { reply = properties.reply } - local request = state.permission_requests_by_id[properties.requestID] + observation._v1_permission_terminal[reply.request_id] = { reply = reply.reply } + local request = state.permission_requests_by_id[reply.request_id] if request then request.status = 'answered' - request.answer = properties.reply + request.answer = reply.reply end return 'permissions' elseif kind == 'question.asked' then diff --git a/lua/opencode/protocols/v2/normalize.lua b/lua/opencode/protocols/v2/normalize.lua index 0e745f35..4b6e124c 100644 --- a/lua/opencode/protocols/v2/normalize.lua +++ b/lua/opencode/protocols/v2/normalize.lua @@ -50,12 +50,31 @@ local function inbox_shape(status) end) end +local permission_preview_shape = v.object({ + command = v.optional('string'), + diff = v.optional('string'), + filePath = v.optional('string'), + path = v.optional('string'), +}):convert(function(metadata) + return { command = metadata.command, diff = metadata.diff, path = metadata.filePath or metadata.path } +end) + +local permission_source_shape = v.object({ type = v.literal('tool'), messageID = 'string', id = 'string' }) + local permission_shape = v.object({ id = 'string', sessionID = 'string', action = 'string', resources = v.table(), + metadata = v.optional(permission_preview_shape), + source = v.optional(permission_source_shape), + message = v.optional('string'), }):convert(function(request) + local preview = request.metadata + if request.source then + preview = preview or {} + preview.source = { message_id = request.source.messageID, call_id = request.source.id } + end return { id = request.id, session_id = request.sessionID, @@ -69,6 +88,7 @@ local permission_shape = v.object({ status = 'pending', message = request.message, source = vim.deepcopy(request.source), + preview = vim.deepcopy(preview), } end) @@ -338,6 +358,7 @@ local file_tool_input_shape = v.object({ end) local skill_metadata_shape = v.object({ name = v.optional('string') }) +local shell_tool_input_shape = v.object({ command = v.optional('string'), description = v.optional('string') }) local tool_metadata_shape = v.object({ diff = v.optional('string'), files = v.optional(v.array(tool_file_change_shape)), @@ -395,6 +416,12 @@ end ---@param name string ---@param input any local function apply_tool_input(result, name, input) + if name == 'bash' or name == 'shell' then + local parsed = shell_tool_input_shape:parse(input, 'V2 observation: invalid shell tool input') + result.command = parsed.command + result.description = parsed.description + return + end if name ~= 'read' and name ~= 'edit' and name ~= 'write' then return end @@ -817,6 +844,47 @@ local function mapped_permission(request) return result end +---Request metadata stays authoritative; only the explicitly linked tool can fill missing preview fields. +---@param state OpencodeObservationState +---@return boolean +local function enrich_permission_previews(state) + local changed = false + for _, request in pairs(state.permission_requests_by_id) do + local preview = request.preview + local source = preview and preview.source + local entry = source and state.entries_by_id[source.message_id] + if request.status == 'pending' and preview and source and entry then + for _, content in ipairs(entry.content) do + ---@cast content {kind: string, call_id?: string, command?: string, description?: string, target?: {path: string}, changes?: OpencodeV2ToolChange[]} + if content.kind == 'tool' and content.call_id == source.call_id then + if not request.message and content.description then + request.message = content.description + changed = true + end + if not preview.command and content.command then + preview.command = content.command + changed = true + end + if not preview.path and content.target then + preview.path = content.target.path + changed = true + end + local changes = content.changes + if not preview.diff and changes and #changes == 1 then + local change = changes[1] + if not preview.path or preview.path == change.path then + preview.diff = change.diff + preview.path = change.path + changed = true + end + end + end + end + end + end + return changed +end + ---@param form table ---@return OpencodeV2QuestionRequest local function mapped_question(form) @@ -836,5 +904,6 @@ return { mapped_session = mapped_session, mapped_inbox = mapped_inbox, mapped_permission = mapped_permission, + enrich_permission_previews = enrich_permission_previews, mapped_question = mapped_question, } diff --git a/lua/opencode/protocols/v2/observation/events.lua b/lua/opencode/protocols/v2/observation/events.lua index a616ba8e..74993ab7 100644 --- a/lua/opencode/protocols/v2/observation/events.lua +++ b/lua/opencode/protocols/v2/observation/events.lua @@ -3,6 +3,7 @@ local lifecycle = require('opencode.protocols.observation') local boundary = require('opencode.protocols.v2.observation.boundary') local messages = require('opencode.protocols.v2.observation.messages') local actions = require('opencode.protocols.v2.observation.actions') +local event_capture = require('opencode.event_capture') local M = {} @@ -419,10 +420,20 @@ function M.route(connection, event) end end + if normalize.enrich_permission_previews(observation:read()) then + changed.permissions = true + end for resource in pairs(changed) do observation:_event_changed(resource) end end + event_capture.record({ + protocol = 'v2', + id = event.id, + type = event.type, + created = event.created, + data = event.data, + }) vim.api.nvim_exec_autocmds('User', { pattern = 'OpencodeEvent:' .. event.type, data = { event = { type = event.type, properties = event.data } }, diff --git a/lua/opencode/protocols/v2/observation/resources.lua b/lua/opencode/protocols/v2/observation/resources.lua index 9746d604..80b6c7d2 100644 --- a/lua/opencode/protocols/v2/observation/resources.lua +++ b/lua/opencode/protocols/v2/observation/resources.lua @@ -138,6 +138,7 @@ end ---@param value table function M.apply(observation, resource, value) apply[resource](observation, value) + normalize.enrich_permission_previews(observation:read()) end ---@param observation OpencodeV2Observation diff --git a/lua/opencode/protocols/v2/types.lua b/lua/opencode/protocols/v2/types.lua index 3a50f142..5098a8fa 100644 --- a/lua/opencode/protocols/v2/types.lua +++ b/lua/opencode/protocols/v2/types.lua @@ -203,6 +203,7 @@ ---@field status string ---@field message? string ---@field source? table +---@field preview? OpencodePermissionPreview ---@field answer? string ---@class OpencodeV2QuestionRequest diff --git a/lua/opencode/ui/debug_helper.lua b/lua/opencode/ui/debug_helper.lua index b0f8e316..cc15cfaf 100644 --- a/lua/opencode/ui/debug_helper.lua +++ b/lua/opencode/ui/debug_helper.lua @@ -3,8 +3,11 @@ ---@field debug_output fun() ---@field debug_message fun() ---@field debug_session fun() +---@field save_captured_events fun(filename?: string): integer|nil local M = {} +local event_capture = require('opencode.event_capture') + local state = require('opencode.state') function M.open_json_file(data) @@ -59,4 +62,8 @@ function M.debug_session() M.open_json_file(observation:read()) end +function M.save_captured_events(filename) + return event_capture.save(filename) +end + return M diff --git a/lua/opencode/ui/formatter.lua b/lua/opencode/ui/formatter.lua index 3310f089..fa83fa2f 100644 --- a/lua/opencode/ui/formatter.lua +++ b/lua/opencode/ui/formatter.lua @@ -303,6 +303,9 @@ function M.format_message_header(message, previous_message) { ' ' }, { display_name, role_hl }, } + if role == 'user' and message.queued then + header_virt_text[#header_virt_text + 1] = { ' QUEUED', 'OpencodeQueued' } + end vim.list_extend(header_virt_text, { { ' ' }, { model_text, 'OpencodeHint' }, @@ -476,11 +479,12 @@ function M._format_diagnostics_context(output, part) return end - local diagnostics_count = { error = 0, warn = 0, info = 0 } + local diagnostics_count = { error = 0, warn = 0, info = 0, hint = 0 } local diagnostics_icons = { error = icons.get('error'), warn = icons.get('warning'), info = icons.get('info'), + hint = icons.get('info'), } for _, diag in ipairs(diagnostics) do @@ -512,6 +516,10 @@ local function get_visible_user_part_kind(part) return 'file' end + if part.kind == 'editor_context' and part.source then + return part.source.kind == 'cursor' and 'cursor-data' or part.source.kind + end + if part.kind ~= 'text' or not part.text or part.text == '' then return nil end @@ -520,10 +528,6 @@ local function get_visible_user_part_kind(part) return 'text' end - if part.kind == 'editor_context' and part.source then - return part.source.kind - end - return nil end diff --git a/lua/opencode/ui/icons.lua b/lua/opencode/ui/icons.lua index 6d64a821..20595bbb 100644 --- a/lua/opencode/ui/icons.lua +++ b/lua/opencode/ui/icons.lua @@ -45,6 +45,7 @@ local presets = { review_comment = '󰆈 ', command = ' ', bash = ' ', + shell = ' ', preferred = ' ', last_used = '󰃰 ', completed = '󰄳 ', @@ -94,6 +95,7 @@ local presets = { review_comment = 'R ', command = '::', bash = '$ ', + shell = '$ ', preferred = '* ', last_used = '~ ', completed = 'X ', diff --git a/lua/opencode/ui/permission_window.lua b/lua/opencode/ui/permission_window.lua index a7b72ef3..cc31a117 100644 --- a/lua/opencode/ui/permission_window.lua +++ b/lua/opencode/ui/permission_window.lua @@ -13,6 +13,7 @@ local formatter_utils = require('opencode.ui.formatter.utils') ---@field message? string ---@field patterns? (string|table)[] ---@field resources? (string|table)[] +---@field preview? OpencodePermissionPreview ---@class PermissionInteraction ---@field permission_id string @@ -104,7 +105,7 @@ end ---@param permission PermissionRequest? ---@return string? local function get_child_session_id(permission) - local session_id = permission and permission.session_id + local session_id = permission and (permission.session_id or permission.sessionID) local active_session = state.active_session if not session_id or session_id == '' or (active_session and active_session.id == session_id) then return nil @@ -139,7 +140,7 @@ function M.remove_permission(permission_id) for i, permission in ipairs(M._permission_queue) do if permission.id == permission_id then - local runtime = session_tabs.find_by_session_id(permission.sessionID) + local runtime = session_tabs.find_by_session_id(permission.session_id or permission.sessionID) if runtime then session_tabs.remove_pending_permission(runtime.id, permission_id) end @@ -188,23 +189,49 @@ function M.format_display(output) local perm_type = permission.permission or permission.action or '' local description = permission.message local patterns = permission.patterns or permission.resources or {} + local preview = permission.preview if description and description ~= '' then table.insert(content, (icons.get(perm_type)) .. ' *' .. perm_type .. '* ' .. description) + elseif preview and preview.title then + table.insert(content, (icons.get(perm_type)) .. ' *' .. perm_type .. '* `' .. preview.title .. '`') else table.insert(content, (icons.get(perm_type)) .. ' *' .. perm_type .. '*') - table.insert(content, string.format('```%s', perm_type)) - for _, pattern in ipairs(patterns) do - pattern = type(pattern) == 'string' and pattern or vim.inspect(pattern) - for _, line in ipairs(vim.split(pattern, '\n')) do - table.insert(content, line) + if not preview or (not preview.command and not preview.diff) then + table.insert(content, string.format('```%s', perm_type)) + for _, pattern in ipairs(patterns) do + pattern = type(pattern) == 'string' and pattern or vim.inspect(pattern) + for _, line in ipairs(vim.split(pattern, '\n')) do + table.insert(content, line) + end end + table.insert(content, '```') end - table.insert(content, '```') end table.insert(content, '') + if preview and preview.command then + table.insert(content, string.format('```%s', perm_type)) + vim.list_extend(content, vim.split(preview.command, '\n')) + table.insert(content, '```') + end + + local render_content + if preview and preview.diff then + local diff = preview.diff + local file_type = '' + if preview.path then + file_type = vim.fn.fnamemodify(preview.path, ':e') + end + render_content = function(out) + out:add_line(content[1]) + out:add_line('') + out:add_line('') + formatter_utils.format_diff(out, diff, file_type) + end + end + local options = { { label = 'Allow once' }, { label = 'Reject' }, @@ -220,6 +247,7 @@ function M.format_display(output) title_hl = 'OpencodePermissionTitle', border_hl = 'OpencodePermissionBorder', content = content, + render_content = render_content, options = options, unfocused_message = 'Focus Opencode window to respond to permission', legend_lines = legend_lines, @@ -447,8 +475,10 @@ function M.sync(observations) end end table.sort(pending, function(left, right) - if left.session_id ~= right.session_id then - return (left.session_id or '') < (right.session_id or '') + local left_session = left.session_id or left.sessionID or '' + local right_session = right.session_id or right.sessionID or '' + if left_session ~= right_session then + return left_session < right_session end return left.id < right.id end) diff --git a/lua/opencode/ui/renderer/entries.lua b/lua/opencode/ui/renderer/entries.lua index 2b6fb2a2..15ed9f31 100644 --- a/lua/opencode/ui/renderer/entries.lua +++ b/lua/opencode/ui/renderer/entries.lua @@ -19,6 +19,7 @@ local function message_snapshot(entry, previous) id = entry.id, kind = entry.kind, agent = entry.agent, + queued = entry.queued, model = entry.model, created = entry.time and entry.time.created, error = entry.error, diff --git a/run_tests.sh b/run_tests.sh index f9478db6..823fdd77 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -82,7 +82,7 @@ strip_ansi() { has_failures() { local plain_output plain_output=$(strip_ansi "$1") - grep -Eq "Fail.*\|\||Failed[[:space:]]*:[[:space:]]*[1-9][0-9]*" <<<"$plain_output" + grep -Eq "^Fail.*\|\||^Failed[[:space:]]*:[[:space:]]*[1-9][0-9]*" <<<"$plain_output" } # List spec files whose busted subprocess died outside of normal assertion @@ -160,9 +160,12 @@ if [ "$TEST_TYPE" = "all" ] || [ "$TEST_TYPE" = "unit" ]; then fi if [ "$TEST_TYPE" = "all" ] || [ "$TEST_TYPE" = "replay" ]; then - replay_output=$(nvim --headless -u tests/minimal/init.lua -c "lua require('plenary.test_harness').test_directory('./tests/replay', {minimal_init = './tests/minimal/init.lua'$FILTER_OPTION})" 2>&1) - replay_status=$? - clean_output "$replay_output" + replay_log=$(mktemp) + nvim --headless -u tests/minimal/init.lua -c "lua require('plenary.test_harness').test_directory('./tests/replay', {minimal_init = './tests/minimal/init.lua'$FILTER_OPTION})" 2>&1 \ + | tee "$replay_log" | grep --line-buffered -v "\[31mErrors : " + replay_status=${PIPESTATUS[0]} + replay_output=$(cat "$replay_log") + rm -f "$replay_log" if [ "$replay_status" -ne 0 ] || has_failures "$replay_output"; then echo -e "${RED}✗ Replay tests failed${NC}" @@ -221,10 +224,10 @@ if has_failures "$all_output" \ # Extract and format failures failures_file=$(mktemp) plain_output=$(strip_ansi "$all_output") - echo "$plain_output" | grep -B 0 -A 6 "Fail.*||" >"$failures_file" - failure_count=$(echo "$plain_output" | grep -c "Fail.*||") + echo "$plain_output" | grep -B 0 -A 6 "^Fail.*||" >"$failures_file" + failure_count=$(echo "$plain_output" | grep -c "^Fail.*||") if [ "$failure_count" -eq 0 ]; then - failure_count=$(echo "$plain_output" | grep -E "Failed[[:space:]]*:[[:space:]]*[1-9][0-9]*" | sed -E 's/.*Failed[[:space:]]*:[[:space:]]*([0-9]+).*/\1/' | awk '{sum+=$1} END {print sum+0}') + failure_count=$(echo "$plain_output" | grep -E "^Failed[[:space:]]*:[[:space:]]*[1-9][0-9]*" | sed -E 's/.*Failed[[:space:]]*:[[:space:]]*([0-9]+).*/\1/' | awk '{sum+=$1} END {print sum+0}') fi echo -e "${RED}Found $failure_count failing test(s):${NC}\n" @@ -243,7 +246,7 @@ if has_failures "$all_output" \ # Remove ANSI color codes clean_line=$(echo "$line" | sed -E 's/\x1B\[[0-9;]*[mK]//g') - if [[ "$clean_line" == *"Fail"*"||"* ]]; then + if [[ "$clean_line" == Fail*"||"* ]]; then # Extract test name test_name=$(echo "$clean_line" | sed -E 's/.*Fail.*\|\|\s*(.*)/\1/') echo -e "${RED}FAILED TEST:${NC} $test_name" diff --git a/scripts/dependency-topology/topology.jsonc b/scripts/dependency-topology/topology.jsonc index 9b133789..5e554953 100644 --- a/scripts/dependency-topology/topology.jsonc +++ b/scripts/dependency-topology/topology.jsonc @@ -172,6 +172,7 @@ "opencode.id", // ID generation "opencode.curl", // HTTP low-level wrapper "opencode.throttling_emitter", // batching primitive + "opencode.event_capture", // streamed-event capture store (replay fixtures) "opencode.model_state", // local model state file I/O "opencode.lru_cache", // generic LRU container (pure data structure) "opencode.slash_commands", // built-in slash command metadata diff --git a/tests/data/apply-patch.expected.json b/tests/data/apply-patch.expected.json index f479ce69..8bcc32ce 100644 --- a/tests/data/apply-patch.expected.json +++ b/tests/data/apply-patch.expected.json @@ -41,6 +41,25 @@ ], [ 2, + 1, + 0, + { + "ns_id": 3, + "priority": 9, + "right_gravity": true, + "virt_text": [ + [ + " 2026-03-02 16:22:00", + "OpencodeHint" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "right_align", + "virt_text_repeat_linebreak": false + } + ], + [ + 3, 4, 0, { @@ -78,7 +97,26 @@ } ], [ - 3, + 4, + 4, + 0, + { + "ns_id": 3, + "priority": 9, + "right_gravity": true, + "virt_text": [ + [ + " 2026-03-02 16:22:01", + "OpencodeHint" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "right_align", + "virt_text_repeat_linebreak": false + } + ], + [ + 5, 6, 0, { @@ -98,7 +136,7 @@ } ], [ - 4, + 6, 7, 0, { @@ -118,7 +156,7 @@ } ], [ - 5, + 7, 8, 0, { @@ -138,7 +176,7 @@ } ], [ - 6, + 8, 9, 0, { @@ -170,7 +208,7 @@ } ], [ - 7, + 9, 9, 0, { @@ -190,7 +228,7 @@ } ], [ - 8, + 10, 10, 0, { @@ -222,7 +260,7 @@ } ], [ - 9, + 11, 10, 0, { @@ -242,7 +280,7 @@ } ], [ - 10, + 12, 11, 0, { @@ -262,7 +300,7 @@ } ], [ - 11, + 13, 12, 0, { @@ -307,6 +345,6 @@ ], "effective_bottom": 14, "line_count": 15, - "visible_bottom": 15 + "visible_bottom": 14 } } diff --git a/tests/data/apply-patch.json b/tests/data/apply-patch.json index 28c3783a..9249faba 100644 --- a/tests/data/apply-patch.json +++ b/tests/data/apply-patch.json @@ -1,8 +1,8 @@ [ {"properties":{},"type":"server.connected"}, - {"properties":{"info":{"id":"ses_abc123","title":"Create user authentication tests","version":"1.2.10","directory":"/home/user/Projects/my-app"}},"type":"session.created"}, - {"properties":{"info":{"id":"msg_user001","sessionID":"ses_abc123","role":"user","agent":"build"}},"type":"message.updated"}, - {"properties":{"info":{"id":"msg_asst001","sessionID":"ses_abc123","role":"assistant","parentID":"msg_user001","agent":"build"}},"type":"message.updated"}, + {"properties":{"info":{"id":"ses_abc123","slug":"auth-tests","projectID":"project-replay","time":{"created":1772468520000,"updated":1772468520000},"title":"Create user authentication tests","version":"1.2.10","directory":"/home/user/Projects/my-app"}},"type":"session.created"}, + {"properties":{"info":{"id":"msg_user001","sessionID":"ses_abc123","role":"user","agent":"build","time":{"created":1772468520000}}},"type":"message.updated"}, + {"properties":{"info":{"id":"msg_asst001","sessionID":"ses_abc123","role":"assistant","parentID":"msg_user001","agent":"build","time":{"created":1772468521000}}},"type":"message.updated"}, {"properties":{"part":{"id":"prt_tool001","callID":"call_abc123","state":{"status":"running","input":{"patchText":"*** Begin Patch\n*** Add File: src/app/features/auth/__tests__/LoginForm.test.tsx\n+import React from 'react'\n+// ... test content\n*** End Patch"},"time":{"start":1772468525525}},"sessionID":"ses_abc123","type":"tool","messageID":"msg_asst001","tool":"apply_patch"}},"type":"message.part.updated"}, {"properties":{"part":{"id":"prt_tool001","callID":"call_abc123","state":{"status":"completed","output":"Success. Updated the following files:\nA src/app/features/auth/__tests__/LoginForm.test.tsx","metadata":{"truncated":false,"diagnostics":{},"files":[{"filePath":"/home/user/Projects/my-app/src/app/features/auth/__tests__/LoginForm.test.tsx","relativePath":"src/app/features/auth/__tests__/LoginForm.test.tsx","before":"","after":"import React from 'react'\n","additions":42,"deletions":0,"type":"add","diff":"Index: /home/user/Projects/my-app/src/app/features/auth/__tests__/LoginForm.test.tsx\n===================================================================\n--- /home/user/Projects/my-app/src/app/features/auth/__tests__/LoginForm.test.tsx\n+++ /home/user/Projects/my-app/src/app/features/auth/__tests__/LoginForm.test.tsx\n@@ -0,0 +1,3 @@\n+import React from 'react'\n+// minimal diff for testing\n"}],"diff":"Index: /home/user/Projects/my-app/src/app/features/auth/__tests__/LoginForm.test.tsx\n===================================================================\n--- /home/user/Projects/my-app/src/app/features/auth/__tests__/LoginForm.test.tsx\n+++ /home/user/Projects/my-app/src/app/features/auth/__tests__/LoginForm.test.tsx\n@@ -0,0 +1,3 @@\n+import React from 'react'\n+// minimal diff for testing\n"},"input":{"patchText":"*** Begin Patch\n*** Add File: src/app/features/auth/__tests__/LoginForm.test.tsx\n+import React from 'react'\n*** End Patch"},"time":{"start":1772468525525,"end":1772468529137},"title":"Success. Updated the following files:\nA src/app/features/auth/__tests__/LoginForm.test.tsx"},"sessionID":"ses_abc123","type":"tool","messageID":"msg_asst001","tool":"apply_patch"}},"type":"message.part.updated"}, {"properties":{"sessionID":"ses_abc123","status":{"type":"idle"}},"type":"session.status"}, diff --git a/tests/data/diff.json b/tests/data/diff.json index 04ddf8a3..c3a47811 100644 --- a/tests/data/diff.json +++ b/tests/data/diff.json @@ -94,9 +94,9 @@ "source": { "type": "file", "text": { - "start": 0, + "start": 39, "value": "@diff-test.txt", - "end": 13 + "end": 53 }, "path": "/Users/cam/tmp/a/diff-test.txt" }, diff --git a/tests/data/explore.expected.json b/tests/data/explore.expected.json index eef23998..a28300ba 100644 --- a/tests/data/explore.expected.json +++ b/tests/data/explore.expected.json @@ -78,7 +78,7 @@ " " ], [ - "", + "github-copilot/claude-sonnet-4.6", "OpencodeHint" ], [ @@ -1607,63 +1607,6 @@ "virt_text_repeat_linebreak": true, "virt_text_win_col": -1 } - ], - [ - 77, - 79, - 0, - { - "ns_id": 3, - "priority": 10, - "right_gravity": true, - "virt_text": [ - [ - " ", - "OpencodeMessageRoleAssistant" - ], - [ - " " - ], - [ - "BUILD", - "OpencodeMessageRoleAssistant" - ], - [ - " " - ], - [ - "github-copilot/claude-sonnet-4.6", - "OpencodeHint" - ], - [ - " [msg_cbe0d01950013Y9CVnC8K3Yyef]", - "OpencodeHint" - ] - ], - "virt_text_hide": false, - "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": false, - "virt_text_win_col": -3 - } - ], - [ - 78, - 79, - 0, - { - "ns_id": 3, - "priority": 9, - "right_gravity": true, - "virt_text": [ - [ - " 2026-03-05 12:50:49", - "OpencodeHint" - ] - ], - "virt_text_hide": false, - "virt_text_pos": "right_align", - "virt_text_repeat_linebreak": false - } ] ], "lines": [ @@ -1677,7 +1620,7 @@ "", "** task** `Find task tool rendering (@explore)` 60s", "", - " ** read** `/home/francis/Projects/_nvim/opencode.nvim/`", + " ** read** `/home/francis/Projects/_nvim/opencode.nvim`", " ** glob** `**/*.lua`", " ** grep** `/home/francis/Projects/_nvim/opencode.nvim/lua task`", " ** read** `/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/output.lua`", diff --git a/tests/data/mcp-tool.expected.json b/tests/data/mcp-tool.expected.json index ad48b20b..8b1dd0e1 100644 --- a/tests/data/mcp-tool.expected.json +++ b/tests/data/mcp-tool.expected.json @@ -155,7 +155,7 @@ " " ], [ - "BUILD", + "ASSISTANT", "OpencodeMessageRoleAssistant" ], [ @@ -385,6 +385,66 @@ 19, 14, 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodeToolBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -1 + } + ], + [ + 20, + 15, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodeToolBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -1 + } + ], + [ + 21, + 16, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodeToolBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -1 + } + ], + [ + 22, + 18, + 0, { "line_hl_group": "OpencodeHint", "ns_id": 3, @@ -393,8 +453,8 @@ } ], [ - 20, - 14, + 23, + 18, 0, { "ns_id": 3, @@ -413,8 +473,8 @@ } ], [ - 21, - 15, + 24, + 19, 0, { "line_hl_group": "OpencodeHint", @@ -424,8 +484,8 @@ } ], [ - 22, - 15, + 25, + 19, 0, { "ns_id": 3, @@ -444,8 +504,8 @@ } ], [ - 23, - 16, + 26, + 20, 0, { "line_hl_group": "OpencodeHint", @@ -455,8 +515,8 @@ } ], [ - 24, - 16, + 27, + 20, 0, { "ns_id": 3, @@ -475,8 +535,8 @@ } ], [ - 25, - 17, + 28, + 21, 0, { "line_hl_group": "OpencodeHint", @@ -486,8 +546,8 @@ } ], [ - 26, - 17, + 29, + 21, 0, { "ns_id": 3, @@ -506,8 +566,8 @@ } ], [ - 27, - 18, + 30, + 22, 0, { "line_hl_group": "OpencodeHint", @@ -517,8 +577,8 @@ } ], [ - 28, - 18, + 31, + 22, 0, { "ns_id": 3, @@ -537,8 +597,8 @@ } ], [ - 29, - 19, + 32, + 23, 0, { "line_hl_group": "OpencodeHint", @@ -548,8 +608,8 @@ } ], [ - 30, - 19, + 33, + 23, 0, { "ns_id": 3, @@ -568,8 +628,8 @@ } ], [ - 31, - 20, + 34, + 24, 0, { "line_hl_group": "OpencodeHint", @@ -579,8 +639,8 @@ } ], [ - 32, - 20, + 35, + 24, 0, { "ns_id": 3, @@ -599,8 +659,8 @@ } ], [ - 33, - 21, + 36, + 25, 0, { "line_hl_group": "OpencodeHint", @@ -610,8 +670,68 @@ } ], [ - 34, - 21, + 37, + 25, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodeToolBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -1 + } + ], + [ + 38, + 26, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodeToolBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -1 + } + ], + [ + 39, + 27, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodeToolBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -1 + } + ], + [ + 40, + 28, 0, { "ns_id": 3, @@ -645,6 +765,10 @@ "", "First, I need to understand the core issue.", "", + "**Result**", + "", + "{\"thoughtNumber\": 1, \"totalThoughts\": 2, \"nextThoughtNeeded\": true, \"branches\": [], \"thoughtHistoryLength\": 1}", + "", " sequential-thinking: sequentialthinking", "", "Second thought: the analysis is complete.", @@ -653,17 +777,21 @@ "- Finding A", "- Finding B", "", + "**Result**", + "", + "{\"thoughtNumber\": 2, \"totalThoughts\": 2, \"nextThoughtNeeded\": false, \"branches\": [], \"thoughtHistoryLength\": 2}", + "", "Based on my analysis, here is the answer.", "", "" ], "window": { "cursor": [ - 24, + 32, 0 ], - "effective_bottom": 24, - "line_count": 25, - "visible_bottom": 25 + "effective_bottom": 32, + "line_count": 33, + "visible_bottom": 32 } } diff --git a/tests/data/mcp-tool.json b/tests/data/mcp-tool.json index ac8cb767..4f08c8ff 100644 --- a/tests/data/mcp-tool.json +++ b/tests/data/mcp-tool.json @@ -121,7 +121,12 @@ "start": 1766000001000, "end": 1766000001500 }, - "input": {} + "input": { + "thought": "Let me analyze this problem step by step.\n\nFirst, I need to understand the core issue.", + "nextThoughtNeeded": true, + "thoughtNumber": 1, + "totalThoughts": 2 + } } } } @@ -234,4 +239,4 @@ } } } -] \ No newline at end of file +] diff --git a/tests/data/mentions-with-ranges.json b/tests/data/mentions-with-ranges.json index 920d6773..3b9ffed4 100644 --- a/tests/data/mentions-with-ranges.json +++ b/tests/data/mentions-with-ranges.json @@ -62,7 +62,7 @@ "type": "file", "text": { "value": "@lua/opencode/ui/streaming_renderer.lua", - "end": 43, + "end": 44, "start": 5 } }, @@ -111,7 +111,7 @@ "type": "file", "text": { "value": "@tests/data/planning.json", - "end": 215, + "end": 216, "start": 191 } }, diff --git a/tests/data/message-removal.expected.json b/tests/data/message-removal.expected.json index 45a36f2f..92426529 100644 --- a/tests/data/message-removal.expected.json +++ b/tests/data/message-removal.expected.json @@ -275,7 +275,7 @@ " " ], [ - "BUILD", + "ASSISTANT", "OpencodeMessageRoleAssistant" ], [ @@ -332,7 +332,7 @@ " " ], [ - "BUILD", + "ASSISTANT", "OpencodeMessageRoleAssistant" ], [ diff --git a/tests/data/multiple-messages-synthetic.expected.json b/tests/data/multiple-messages-synthetic.expected.json index ee8f0c0b..3d2862a8 100644 --- a/tests/data/multiple-messages-synthetic.expected.json +++ b/tests/data/multiple-messages-synthetic.expected.json @@ -233,7 +233,7 @@ " " ], [ - "BUILD", + "ASSISTANT", "OpencodeMessageRoleAssistant" ], [ @@ -387,7 +387,7 @@ " " ], [ - "BUILD", + "ASSISTANT", "OpencodeMessageRoleAssistant" ], [ @@ -541,7 +541,7 @@ " " ], [ - "BUILD", + "ASSISTANT", "OpencodeMessageRoleAssistant" ], [ diff --git a/tests/data/multiple-question-ask-reply-all.expected.json b/tests/data/multiple-question-ask-reply-all.expected.json index ce2a724b..aa16e89d 100644 --- a/tests/data/multiple-question-ask-reply-all.expected.json +++ b/tests/data/multiple-question-ask-reply-all.expected.json @@ -104,7 +104,7 @@ " " ], [ - "", + "github-copilot/gpt-4.1", "OpencodeHint" ], [ diff --git a/tests/data/multiple-question-ask.expected.json b/tests/data/multiple-question-ask.expected.json index 992385f7..0a1368ec 100644 --- a/tests/data/multiple-question-ask.expected.json +++ b/tests/data/multiple-question-ask.expected.json @@ -65,7 +65,7 @@ " " ], [ - "", + "github-copilot/gpt-4.1", "OpencodeHint" ], [ diff --git a/tests/data/part-before-message-delta.expected.json b/tests/data/part-before-message-delta.expected.json index 19b5a44b..c6c8015d 100644 --- a/tests/data/part-before-message-delta.expected.json +++ b/tests/data/part-before-message-delta.expected.json @@ -65,7 +65,7 @@ " " ], [ - "", + "anthropic/claude-sonnet-4-5", "OpencodeHint" ], [ diff --git a/tests/data/part-before-message-delta.json b/tests/data/part-before-message-delta.json index 19522f9f..6eedd90a 100644 --- a/tests/data/part-before-message-delta.json +++ b/tests/data/part-before-message-delta.json @@ -51,5 +51,17 @@ "tokens": { "input": 100, "output": 20, "reasoning": 0, "cache": { "read": 0, "write": 0 } } } } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_0000000000002", + "messageID": "msg_0000000000002", + "sessionID": "ses_0000000000001", + "type": "text", + "text": "Sure, I can help with that." + } + } } ] diff --git a/tests/data/permission-ask-new-approve.expected.json b/tests/data/permission-ask-new-approve.expected.json index dcd692cd..0a028bc0 100644 --- a/tests/data/permission-ask-new-approve.expected.json +++ b/tests/data/permission-ask-new-approve.expected.json @@ -104,7 +104,7 @@ " " ], [ - "", + "github-copilot/gpt-4.1", "OpencodeHint" ], [ diff --git a/tests/data/permission-ask-new-deny.expected.json b/tests/data/permission-ask-new-deny.expected.json index b85b9e6f..50ea4504 100644 --- a/tests/data/permission-ask-new-deny.expected.json +++ b/tests/data/permission-ask-new-deny.expected.json @@ -65,7 +65,7 @@ " " ], [ - "", + "github-copilot/gpt-4.1", "OpencodeHint" ], [ diff --git a/tests/data/permission-ask-new.expected.json b/tests/data/permission-ask-new.expected.json index a0f57d75..b52cd58f 100644 --- a/tests/data/permission-ask-new.expected.json +++ b/tests/data/permission-ask-new.expected.json @@ -65,7 +65,7 @@ " " ], [ - "", + "github-copilot/gpt-4.1", "OpencodeHint" ], [ diff --git a/tests/data/provider-overloaded-status.expected.json b/tests/data/provider-overloaded-status.expected.json index 19b5a44b..c6c8015d 100644 --- a/tests/data/provider-overloaded-status.expected.json +++ b/tests/data/provider-overloaded-status.expected.json @@ -65,7 +65,7 @@ " " ], [ - "", + "anthropic/claude-sonnet-4-5", "OpencodeHint" ], [ diff --git a/tests/data/question-ask-other.expected.json b/tests/data/question-ask-other.expected.json index c25effad..49a37a6e 100644 --- a/tests/data/question-ask-other.expected.json +++ b/tests/data/question-ask-other.expected.json @@ -65,7 +65,7 @@ " " ], [ - "", + "anthropic/claude-sonnet-4-5", "OpencodeHint" ], [ diff --git a/tests/data/question-ask-replied.expected.json b/tests/data/question-ask-replied.expected.json index 808de14f..abdcc0e2 100644 --- a/tests/data/question-ask-replied.expected.json +++ b/tests/data/question-ask-replied.expected.json @@ -65,7 +65,7 @@ " " ], [ - "", + "github-copilot/gpt-4.1", "OpencodeHint" ], [ diff --git a/tests/data/question-ask.expected.json b/tests/data/question-ask.expected.json index 627e0d0c..6b23688d 100644 --- a/tests/data/question-ask.expected.json +++ b/tests/data/question-ask.expected.json @@ -65,7 +65,7 @@ " " ], [ - "", + "github-copilot/gpt-4.1", "OpencodeHint" ], [ diff --git a/tests/data/question-multiple-choices-answered.expected.json b/tests/data/question-multiple-choices-answered.expected.json index de0121c3..3bdaa57b 100644 --- a/tests/data/question-multiple-choices-answered.expected.json +++ b/tests/data/question-multiple-choices-answered.expected.json @@ -65,7 +65,7 @@ " " ], [ - "", + "opencode/deepseek-v4-flash-free", "OpencodeHint" ], [ diff --git a/tests/data/question-multiple-choices.expected.json b/tests/data/question-multiple-choices.expected.json index 6ed05974..2780459f 100644 --- a/tests/data/question-multiple-choices.expected.json +++ b/tests/data/question-multiple-choices.expected.json @@ -65,7 +65,7 @@ " " ], [ - "", + "opencode/deepseek-v4-flash-free", "OpencodeHint" ], [ diff --git a/tests/data/question-multiple-other.expected.json b/tests/data/question-multiple-other.expected.json index 16318624..4d6d37d9 100644 --- a/tests/data/question-multiple-other.expected.json +++ b/tests/data/question-multiple-other.expected.json @@ -65,7 +65,7 @@ " " ], [ - "", + "opencode/big-pickle", "OpencodeHint" ], [ diff --git a/tests/data/queue.expected.json b/tests/data/queue.expected.json index 01e2fc59..8903e7ea 100644 --- a/tests/data/queue.expected.json +++ b/tests/data/queue.expected.json @@ -194,7 +194,7 @@ " " ], [ - "BUILD", + "ASSISTANT", "OpencodeMessageRoleAssistant" ], [ diff --git a/tests/data/selection.expected.json b/tests/data/selection.expected.json index d0a6c336..df231b60 100644 --- a/tests/data/selection.expected.json +++ b/tests/data/selection.expected.json @@ -240,7 +240,7 @@ ], [ 10, - 8, + 9, 0, { "ns_id": 3, @@ -260,7 +260,7 @@ ], [ 11, - 9, + 10, 0, { "ns_id": 3, @@ -280,7 +280,7 @@ ], [ 12, - 10, + 11, 0, { "ns_id": 3, @@ -300,7 +300,7 @@ ], [ 13, - 11, + 12, 0, { "ns_id": 3, @@ -340,26 +340,6 @@ ], [ 15, - 12, - 0, - { - "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], - "virt_text_hide": false, - "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": true, - "virt_text_win_col": -3 - } - ], - [ - 16, 13, 0, { @@ -379,7 +359,7 @@ } ], [ - 17, + 16, 16, 0, { @@ -417,7 +397,7 @@ } ], [ - 18, + 17, 16, 0, { diff --git a/tests/data/shifting-and-multiple-perms.expected.json b/tests/data/shifting-and-multiple-perms.expected.json index 2cdd45ab..7155c2b0 100644 --- a/tests/data/shifting-and-multiple-perms.expected.json +++ b/tests/data/shifting-and-multiple-perms.expected.json @@ -1606,7 +1606,7 @@ " *edit* `Edit this file: /Users/cam/Dev/neovim-dev/opencode.nvim/lua/opencode/ui/renderer.lua`", "", "", - "`````", + "`````lua", " M._part_cache = {}", " M._prev_line_count = 0", " M._message_map = MessageMap.new()", diff --git a/tests/data/tool-invalid.expected.json b/tests/data/tool-invalid.expected.json index e5a09415..99384fac 100644 --- a/tests/data/tool-invalid.expected.json +++ b/tests/data/tool-invalid.expected.json @@ -177,6 +177,226 @@ "virt_text_repeat_linebreak": true, "virt_text_win_col": -1 } + ], + [ + 9, + 9, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodeToolBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -1 + } + ], + [ + 10, + 10, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodeToolBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -1 + } + ], + [ + 11, + 11, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodeToolBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -1 + } + ], + [ + 12, + 12, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodeToolBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -1 + } + ], + [ + 13, + 13, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodeToolBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -1 + } + ], + [ + 14, + 14, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodeToolBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -1 + } + ], + [ + 15, + 15, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodeToolBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -1 + } + ], + [ + 16, + 16, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodeToolBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -1 + } + ], + [ + 17, + 17, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodeToolBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -1 + } + ], + [ + 18, + 18, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodeToolBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -1 + } + ], + [ + 19, + 19, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodeToolBorder" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -1 + } ] ], "lines": [ @@ -185,6 +405,17 @@ "", "** tool** `invalid`", "", + "**Input**", + "", + "`````json", + "{\"error\":\"Invalid input for tool edit: JSON parsing failed: Text: {\\\"filePath\\\": \\\"/Users/cam/Dev/neovim-dev/opencode.nvim/lua/opencode/ui/streaming_renderer.lua\\\", \\\"newString\\\": \\\"---Event handler for permission.replied events\\\\n---Re-renders part after permission is resolved\\\\n---@param event table Event object\\\\nfunctio.\\nError message: JSON Parse error: Unterminated string\",\"tool\":\"edit\"}", + "`````", + "", + "**Result**", + "", + "The arguments provided to the tool are invalid: Invalid input for tool edit: JSON parsing failed: Text: {\"filePath\": \"/Users/cam/Dev/neovim-dev/opencode.nvim/lua/opencode/ui/streaming_renderer.lua\", \"newString\": \"---Event handler for permission.replied events\\n---Re-renders part after permission is resolved\\n---@param event table Event object\\nfunctio.", + "Error message: JSON Parse error: Unterminated string", + "", "> [!ERROR]", ">", "> Invalid input for tool edit: JSON parsing failed: Text: {\"filePath\": \"/Users/cam/Dev/neovim-dev/opencode.nvim/lua/opencode/ui/streaming_renderer.lua\", \"newString\": \"---Event handler for permission.replied events\\n---Re-renders part after permission is resolved\\n---@param event table Event object\\nfunctio.", @@ -195,11 +426,11 @@ "timestamp": 1770935243, "window": { "cursor": [ - 10, + 21, 0 ], - "effective_bottom": 10, - "line_count": 11, - "visible_bottom": 11 + "effective_bottom": 21, + "line_count": 22, + "visible_bottom": 21 } } diff --git a/tests/data/v2/README.md b/tests/data/v2/README.md index 4a083171..cd8ee0df 100644 --- a/tests/data/v2/README.md +++ b/tests/data/v2/README.md @@ -18,4 +18,4 @@ These synthetic V2 message fixtures have matching `.expected.json` renderer snap - `mcp-results.json`: running-to-completed MCP input, Markdown results, an HTTP attachment, and a named inline attachment. - `generic-results.json`: custom-tool JSON input, local and unnamed inline attachments, an empty-input browser tool, and a namespaced session tool with partial results and an error. -Run `./run_tests.sh -t tests/replay/formatter_snapshot_spec.lua` to compare buffer lines, extmarks, and actions across full rendering, incremental replay, and reset/replay. +Run `make test-replay` to compare buffer lines, extmarks, and actions across full rendering, incremental replay, and reset/replay. diff --git a/tests/helpers.lua b/tests/helpers.lua index bca32af4..4124f443 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -5,6 +5,47 @@ local M = {} M.MOCK_CWD = '/mock/project/path' +---@type (fun())? +local restore_replay_environment + +function M.restore_replay_environment() + if restore_replay_environment then + restore_replay_environment() + restore_replay_environment = nil + end +end + +function M.isolate_replay_environment() + M.restore_replay_environment() + local fnamemodify = vim.fn.fnamemodify + local reference_facts = require('opencode.ui.reference_facts') + local available_files = reference_facts.available_files + + -- Vim's :~:. uses the real process cwd/home, not the mocked getcwd(). + ---@diagnostic disable-next-line: duplicate-set-field + vim.fn.fnamemodify = function(path, modifiers) + if modifiers == ':~:.' then + local prefix = M.MOCK_CWD .. '/' + return vim.startswith(path, prefix) and path:sub(#prefix + 1) or path + end + return fnamemodify(path, modifiers) + end + -- Captures contain no filesystem availability snapshot. Host files and loaded + -- buffers must not invent reference icons in otherwise identical replay data. + reference_facts.available_files = function() + return {} + end + restore_replay_environment = function() + vim.fn.fnamemodify = fnamemodify + reference_facts.available_files = available_files + end +end + +-- Bootstrap reads must see only events already delivered, including child events +-- delivered before the renderer subscribes to that child's observation. +M._v1_replay_sessions = {} +M._v1_replay_messages = {} + local function resolved(value) return require('opencode.promise').new():resolve(value) end @@ -40,13 +81,31 @@ local function new_replay_connection() return stream end function operations.get_session(_, session_id, location) - return resolved(replay_session(session_id, location)) + return resolved(vim.deepcopy(M._v1_replay_sessions[session_id] or replay_session(session_id, location))) end - function operations.list_children() - return resolved({}) + function operations.list_children(_, session_id) + local children = {} + for _, session in pairs(M._v1_replay_sessions) do + if session.parentID == session_id then + children[#children + 1] = vim.deepcopy(session) + end + end + table.sort(children, function(a, b) + return a.id < b.id + end) + return resolved(children) end - function operations.list_messages() - return resolved({}) + function operations.list_messages(_, session_id) + local messages = {} + for _, message in pairs(M._v1_replay_messages) do + if message.info and message.info.sessionID == session_id then + messages[#messages + 1] = { info = vim.deepcopy(message.info), parts = vim.deepcopy(message.parts) } + end + end + table.sort(messages, function(a, b) + return a.info.id < b.info.id + end) + return resolved(messages) end function operations.list_session_status() return resolved({}) @@ -90,7 +149,97 @@ local function new_replay_connection() return connection end +function M.new_v1_replay_connection() + return new_replay_connection() +end + +function M.new_v2_replay_connection() + local connection = require('opencode.opencode_server').from_custom('http://v2.replay') + connection.protocol = 'v2' + connection.server_identity = { version = '2.0.1' } + connection.credential = { username = 'opencode' } + connection:mark_ready() + + local operations = {} + function operations.subscribe_events(owner, on_chunk, on_disconnect) + local stream = { + on_chunk = on_chunk, + on_disconnect = on_disconnect, + shutdown = function() end, + } + M._replay_stream = stream + owner:set_stream(stream) + return stream + end + function operations.get_session(_, session_id) + return resolved({ + id = session_id, + projectID = 'project-replay', + location = { directory = M.MOCK_CWD }, + title = 'Replay session', + time = { created = 1, updated = 1 }, + }) + end + function operations.list_sessions() + return resolved({ data = {}, cursor = {} }) + end + function operations.list_messages() + return resolved({ data = {}, cursor = {} }) + end + function operations.list_inbox() + return resolved({}) + end + function operations.list_active_sessions() + return resolved({}) + end + function operations.list_permissions() + return resolved({}) + end + function operations.list_questions() + return resolved({}) + end + ---The mock implements only operations exercised by the replay Observation. + ---@cast operations OpencodeV2Operations + connection.operations = operations + return connection +end + +---@param events table[] +---@return 'v1'|'v2'|nil +function M.event_protocol(events) + ---@type 'v1'|'v2'|nil + local protocol + for _, event in ipairs(events) do + local current = event.protocol or 'v1' + if current ~= 'v1' and current ~= 'v2' then + return nil + end + if protocol and protocol ~= current then + return nil + end + protocol = current + end + return protocol or 'v1' +end + +function M.reset_replay_snapshots() + M._v1_replay_sessions = {} + M._v1_replay_messages = {} + M._v2_replay_messages = {} +end + +function M.reset_replay_stream() + M._replay_started = false + M.reset_replay_snapshots() +end + +function M.clear_replay_stream() + M._replay_stream = nil + M._replay_started = false +end + function M.replay_setup() + M.restore_replay_environment() local config = require('opencode.config') local config_file = require('opencode.config_file') local state = require('opencode.state') @@ -116,7 +265,7 @@ function M.replay_setup() state.session.clear_active() M._replay_stream = nil M._replay_started = false - M._v2_replay_messages = {} + M.reset_replay_snapshots() state.jobs.set_server(new_replay_connection()) renderer.reset() @@ -311,17 +460,6 @@ local function native_messages_from_events(events) end if existing_part then - -- Preserve state.input when the later event omits it - local new_input = part.state and part.state.input - local old_input = msg.parts[existing_part].state and msg.parts[existing_part].state.input - if - type(new_input) == 'table' - and next(new_input) == nil - and type(old_input) == 'table' - and next(old_input) ~= nil - then - part.state.input = old_input - end msg.parts[existing_part] = vim.deepcopy(part) parts_by_id[part.id] = msg.parts[existing_part] else @@ -390,6 +528,31 @@ function M.map_v1_messages(messages, session) end function M.load_session_from_events(events) + if M.event_protocol(events) == 'v2' then + local session = M.get_session_from_events(events) + if not session then + return {} + end + local connection = M.new_v2_replay_connection() + local observation = connection:observe(session) + local ingest_event = require('opencode.protocols.v2.observation.messages').ingest_event + for _, event in ipairs(events) do + ingest_event(observation, { + id = event.id, + type = event.type, + created = event.created, + data = event.data, + }) + end + local observed = observation:read() + local entries = {} + for _, message_id in ipairs(observed.entry_order) do + entries[#entries + 1] = observed.entries_by_id[message_id] + end + connection:close() + return entries + end + if events[1] and events[1].type == 'replay.v2.message' then local entries, indices = {}, {} local session = M.get_session_from_events(events) @@ -429,15 +592,24 @@ function M.get_session_from_events(events, with_session_updates) end end for _, event in ipairs(events) do - -- find the session id in a message or part event - local properties = event.properties - local session_id = properties.info and properties.info.sessionID - or properties.part and properties.part.sessionID - or properties.sessionID - - if session_id then - ---@diagnostic disable-next-line: missing-fields - return { id = session_id, location = { directory = M.MOCK_CWD } } + if event.protocol == 'v2' then + local data = event.data + local session_id = data.sessionID + if session_id then + local location = type(data.location) == 'table' and vim.deepcopy(data.location) or { directory = M.MOCK_CWD } + return { id = session_id, location = location, title = data.title } + end + else + -- find the session id in a message or part event + local properties = event.properties + local session_id = properties.info and properties.info.sessionID + or properties.part and properties.part.sessionID + or properties.sessionID + + if session_id then + ---@diagnostic disable-next-line: missing-fields + return { id = session_id, location = { directory = M.MOCK_CWD } } + end end end @@ -457,7 +629,7 @@ function M.wait_for_replay_ready() end) if not ready then local observation = state.session.active_observation() - error('V1 replay Observation did not become current: ' .. vim.inspect({ + error('Replay Observation did not become current: ' .. vim.inspect({ active = state.active_session, stream = M._replay_stream ~= nil, sync = observation and observation:read().sync or nil, @@ -467,7 +639,52 @@ function M.wait_for_replay_ready() end end -function M.replay_event(event) +local function record_v1_replay_event(event) + local properties = event.properties + if type(properties) ~= 'table' then + return + end + if event.type == 'session.created' or event.type == 'session.updated' then + M._v1_replay_sessions[properties.info.id] = vim.deepcopy(properties.info) + elseif event.type == 'session.deleted' then + M._v1_replay_sessions[properties.info.id] = nil + elseif event.type == 'message.updated' then + local info = properties.info + local message = M._v1_replay_messages[info.id] or { parts = {} } + message.info = vim.deepcopy(info) + M._v1_replay_messages[info.id] = message + elseif event.type == 'message.removed' then + M._v1_replay_messages[properties.messageID] = nil + elseif event.type == 'message.part.updated' then + local part = properties.part + local message = M._v1_replay_messages[part.messageID] or { parts = {} } + M._v1_replay_messages[part.messageID] = message + for index, previous in ipairs(message.parts) do + if previous.id == part.id then + message.parts[index] = vim.deepcopy(part) + return + end + end + message.parts[#message.parts + 1] = vim.deepcopy(part) + elseif event.type == 'message.part.delta' or event.type == 'message.part.removed' then + local message = M._v1_replay_messages[properties.messageID] + if not message then + return + end + for index, part in ipairs(message.parts) do + if part.id == properties.partID then + if event.type == 'message.part.removed' then + table.remove(message.parts, index) + elseif (part.type == 'text' or part.type == 'reasoning') and properties.field == 'text' then + part.text = part.text .. properties.delta + end + return + end + end + end +end + +local function dispatch_replay_event(event) local state = require('opencode.state') if type(event) == 'table' and type(event.payload) == 'table' then event = vim.tbl_extend('force', { directory = event.directory }, event.payload) @@ -494,16 +711,30 @@ function M.replay_event(event) ) return end - local active = assert(state.active_session, 'V1 replay requires an active session') - local directory = active.location and active.location.directory or M.MOCK_CWD - local properties = vim.deepcopy(event.properties) - properties.sessionID = properties.sessionID - or (type(properties.info) == 'table' and properties.info.sessionID) - or (type(properties.part) == 'table' and properties.part.sessionID) - M._replay_stream.on_chunk('data: ' .. vim.json.encode({ - directory = event.directory or directory, - payload = { type = event.type, properties = properties }, - }) .. '\n\n') + if event.protocol == 'v2' then + local native_event = vim.deepcopy(event) + native_event.protocol = nil + M._replay_stream.on_chunk('data: ' .. vim.json.encode(native_event) .. '\n\n') + else + record_v1_replay_event(event) + -- Session facts update synchronously; the UI's active session can lag behind a batch. + local observation = assert(state.session.active_observation(), 'V1 replay requires an active observation') + local directory = observation:read().session.location.directory + local properties = vim.deepcopy(event.properties) + properties.sessionID = properties.sessionID + or (type(properties.info) == 'table' and properties.info.sessionID) + or (event.type:match('^session%.') and type(properties.info) == 'table' and properties.info.id) + or (type(properties.part) == 'table' and properties.part.sessionID) + or nil + M._replay_stream.on_chunk('data: ' .. vim.json.encode({ + directory = event.directory or directory, + payload = { type = event.type, properties = properties }, + }) .. '\n\n') + end +end + +function M.replay_event(event) + dispatch_replay_event(event) local rendered = false vim.schedule(function() rendered = true @@ -519,8 +750,9 @@ end function M.replay_events(events) for _, event in ipairs(events) do - M.replay_event(event) + dispatch_replay_event(event) end + assert(require('tests.manual.renderer_replay').wait_for_idle(), 'scheduled replay render did not finish') end function M.normalize_namespace_ids(extmarks) diff --git a/tests/manual/QUICKSTART.md b/tests/manual/QUICKSTART.md index adbd307f..bb5e3b9f 100644 --- a/tests/manual/QUICKSTART.md +++ b/tests/manual/QUICKSTART.md @@ -30,7 +30,7 @@ and directory changes with and without a previous location. Run the renderer snapshot tests: ```bash -./run_tests.sh -t tests/replay/formatter_snapshot_spec.lua +make test-replay ``` Focused fixtures are `tests/data/v2/system-skill.json`, `shell.json`, diff --git a/tests/manual/README.md b/tests/manual/README.md index 3fffd114..82f7957f 100644 --- a/tests/manual/README.md +++ b/tests/manual/README.md @@ -50,9 +50,10 @@ To capture new event data for testing: 1. Set `capture_streamed_events = true` in your config 2. Use OpenCode normally to generate the events you want to capture -3. Use `:lua require('opencode.ui.debug_helper').debug_session()` to inspect the active Observation. -4. The captured events will be saved to `data.json` in the current directory -5. That data can then be loaded with `:ReplayLoad` +3. Use `:lua require('opencode.ui.debug_helper').save_captured_events('data.json')` to save the captured events +4. That data can then be loaded with `:ReplayLoad` + +V1 captures store `type` and `properties`; V2 captures preserve native event fields, including timestamps, and replay through the V2 Observation. Keep each capture file to one protocol. ### Debugging Tips diff --git a/tests/manual/regenerate_expected.lua b/tests/manual/regenerate_expected.lua index e695afd3..ec891775 100644 --- a/tests/manual/regenerate_expected.lua +++ b/tests/manual/regenerate_expected.lua @@ -40,6 +40,7 @@ function M.run(data_file, expected_file) with_ftplugin_disabled(function() helpers.replay_setup() end) + helpers.isolate_replay_environment() local events = helpers.load_test_data(data_file) state.session.set_active(helpers.get_session_from_events(events)) @@ -58,6 +59,7 @@ function M.run(data_file, expected_file) file:close() end, debug.traceback) + helpers.restore_replay_environment() if state.windows then ui.close_windows(state.windows) end diff --git a/tests/manual/renderer_replay.lua b/tests/manual/renderer_replay.lua index 1bac4ceb..962cc4fb 100644 --- a/tests/manual/renderer_replay.lua +++ b/tests/manual/renderer_replay.lua @@ -13,6 +13,10 @@ local M = { headless_mode = false, } +---@type OpencodeServer? +local replay_connection +---@type (fun(connection: OpencodeServer): nil)? +local set_replay_connection ---@type (fun())? local restore_connection_hooks ---@type (fun())? @@ -20,26 +24,53 @@ local restore_environment local function isolate_connection() local server_job = require('opencode.server_job') - local connection = state.opencode_server --[[@as OpencodeServer]] + replay_connection = state.opencode_server --[[@as OpencodeServer]] local ensure_server = server_job.ensure_server local set_server = state.jobs.set_server local clear_server = state.jobs.clear_server -- Interactive replay can run after normal plugin setup, including in-flight discovery. server_job.ensure_server = function() - return require('opencode.promise').new():resolve(connection) + return require('opencode.promise').new():resolve(replay_connection) end state.jobs.set_server = function() - return connection + return replay_connection end state.jobs.clear_server = function() - return connection + return replay_connection + end + + set_replay_connection = function(connection) + if replay_connection and replay_connection ~= connection then + replay_connection:close() + end + replay_connection = connection + set_server(connection) end restore_connection_hooks = function() server_job.ensure_server = ensure_server state.jobs.set_server = set_server state.jobs.clear_server = clear_server + replay_connection = nil + set_replay_connection = nil + end +end + +local function use_protocol(protocol) + local connection = state.opencode_server + if connection and connection.protocol == protocol then + return + end + helpers.clear_replay_stream() + local replacement = protocol == 'v2' and helpers.new_v2_replay_connection() or helpers.new_v1_replay_connection() + if set_replay_connection then + set_replay_connection(replacement) + else + if connection then + connection:close() + end + state.jobs.set_server(replacement) end end @@ -60,9 +91,20 @@ function M.load_events(file_path) vim.notify('Failed to parse JSON: ' .. tostring(events), vim.log.levels.ERROR) return false end + ---@cast events table[] + + local protocol = helpers.event_protocol(events) + if not protocol then + vim.notify('Event data mixes protocols or contains an unsupported protocol', vim.log.levels.ERROR) + return false + end + helpers.isolate_replay_environment() M.events = events M.reset() + state.session.clear_active() + helpers.reset_replay_stream() + use_protocol(protocol) M.last_loaded_file = file_path vim.notify('Loaded ' .. #M.events .. ' events from ' .. data_file, vim.log.levels.INFO) @@ -109,6 +151,7 @@ end function M.exit() M.stop = true + helpers.restore_replay_environment() state.jobs.set_count(0) renderer.reset() state.session.clear_active() @@ -129,15 +172,21 @@ end function M.replay_next(steps) steps = tonumber(steps) or 1 + local events = {} for _ = 1, steps do if M.event_index < #M.events then M.event_index = M.event_index + 1 - helpers.replay_event(M.events[M.event_index]) + events[#events + 1] = M.events[M.event_index] else vim.notify('No more events to replay', vim.log.levels.WARN) - return + break end end + if steps == 1 and #events == 1 then + helpers.replay_event(events[1]) + else + helpers.replay_events(events) + end if M.headless_mode and steps > 1 then M.dump_buffer_and_quit() @@ -190,7 +239,7 @@ function M.reset() M.stop = true M.event_index = 0 M.events_received = 0 - helpers._v2_replay_messages = {} + helpers.reset_replay_snapshots() M.clear() end diff --git a/tests/replay/README.md b/tests/replay/README.md new file mode 100644 index 00000000..91a233f1 --- /dev/null +++ b/tests/replay/README.md @@ -0,0 +1,49 @@ +# JSON snapshot replays + +`make test-replay` discovers `tests/data/**/*.expected.json` and replays the +matching input JSON. Behavior tests live in `tests/unit`. + +Shared snapshot comparison decodes valid fenced JSON before comparing values, +ignoring object-key order and equivalent escaping. Array order, surrounding text, +line positions, extmarks, and actions remain exact. Malformed JSON stays literal. +Single-line unfenced JSON also ignores equivalent slash escaping (`\/` versus +`/`), but its key order remains exact. + +Native event batches settle once per playback, not once per event. Interactive +single-step playback still settles each event. Synthetic V2 message records keep +their full-session rendering behavior. + +Replay path display uses `/mock/project/path`, not the host process cwd or home. +Recorded absolute paths outside that directory stay absolute. Captures have no +filesystem availability snapshot, so replay does not infer reference icons from +host files or loaded buffers. Reference availability remains covered by unit +tests. These environment overrides are restored on replay exit and test cleanup. + +## Restored V1 coverage + +See [SNAPSHOT_TRIAGE.md](SNAPSHOT_TRIAGE.md) for per-fixture classifications, +reviewed updates, confirmed harness defects, and cases that must not be +regenerated blindly. + +The V2 migration (`08f3d1c9`) replaced the V1 fixture loop with three observation +contract tests. Restoring the loop exposes differences that must be reviewed +before regenerating snapshots. + +Batching restores the expected output for fixtures including `simple-session`, +`perf`, `planning`, `api-abort`, and `api-error`. Legacy `session_window` metadata +is a full-session viewport override, not part of an event-replay snapshot. + +Remaining differences include: + +- Header-only differences: missing mode falls back to `ASSISTANT` rather than + `BUILD`; model names now appear in some headers. +- Display changes: relative tool paths, reference icons, generic tool input/result + sections, and prompt spacing. +- Missing content requiring runtime investigation: diagnostics in `diagnostics` + and permission fixtures; selection context in `selection` and `cursor_data`; + child-task output in `explore`; text in `part-before-message-delta`. +- Mention extmarks missing from `mentions-with-ranges` and `diff`. +- Revert/redo differences also affect actions and extmark positions. + +Do not regenerate all expected files to hide these failures. Review display +changes separately from missing content and protocol behavior. diff --git a/tests/replay/formatter_snapshot_spec.lua b/tests/replay/json_snapshot_spec.lua similarity index 51% rename from tests/replay/formatter_snapshot_spec.lua rename to tests/replay/json_snapshot_spec.lua index 6f125216..b5cece19 100644 --- a/tests/replay/formatter_snapshot_spec.lua +++ b/tests/replay/json_snapshot_spec.lua @@ -5,48 +5,54 @@ local output_window = require('opencode.ui.output_window') local replay = require('tests.manual.renderer_replay') local state = require('opencode.state') local ui = require('opencode.ui.ui') +local snapshot_assertions = require('tests.snapshot_assertions') -describe('V2 formatter JSON replay snapshots', function() +describe('JSON replay snapshots', function() local original_show_ids + local original_notify before_each(function() original_show_ids = config.debug.show_ids + original_notify = vim.notify + vim.notify = function(message, level, opts) + if level == vim.log.levels.WARN or level == vim.log.levels.ERROR then + original_notify(message, level, opts) + end + end config.debug.show_ids = true helpers.replay_setup() end) after_each(function() + helpers.restore_replay_environment() config.debug.show_ids = original_show_ids + vim.notify = original_notify ui.close_windows(state.windows) end) - for _, name in ipairs({ - 'formatters', - 'system-skill', - 'shell', - 'compaction', - 'location-switched', - 'execute-results', - 'mcp-results', - 'generic-results', - }) do - it('replays tests/data/v2/' .. name .. '.json into its expected snapshot', function() - local input_file = 'tests/data/v2/' .. name .. '.json' - local expected_file = replay.get_expected_filename(input_file) + local snapshots = vim.fn.glob('tests/data/**/*.expected.json', false, true) + table.sort(snapshots) + for _, expected_file in ipairs(snapshots) do + local input_file = expected_file:gsub('%.expected%.json$', '.json') + it('replays ' .. input_file .. ' into its expected snapshot', function() + io.stdout:write('Replaying: ' .. input_file .. '\n') + io.stdout:flush() local expected = helpers.load_test_data(expected_file) - if vim.fn.has('nvim-0.11') == 0 then - -- Neovim 0.10 escapes forward slashes in JSON; newer versions do not. - for index, line in ipairs(expected.lines) do - if line:match('^{.*}$') then - expected.lines[index] = vim.json.encode(vim.json.decode(line)) - end - end - end assert.is_true(replay.load_events(input_file)) + if replay.events[1].type ~= 'replay.v2.message' and replay.events[1].protocol ~= 'v2' then + replay.replay_all(0) + assert.is_true(replay.wait_for_idle()) + local actual = helpers.output_snapshot(state.windows.output_buf, output_window.namespace, expected_file) + -- Legacy fixtures also carry viewport overrides for full-session rendering. + expected.session_window = nil + actual.window, expected.window = nil, nil + snapshot_assertions.assert_same(expected, actual) + return + end assert.is_true(replay.replay_full_session()) local initial = helpers.output_snapshot(state.windows.output_buf, output_window.namespace, expected_file) initial.window, expected.window = nil, nil - assert.same(expected, initial) + snapshot_assertions.assert_same(expected, initial) replay.reset() replay.replay_next(#replay.events) @@ -55,17 +61,17 @@ describe('V2 formatter JSON replay snapshots', function() local actual = helpers.output_snapshot(state.windows.output_buf, output_window.namespace, expected_file) -- Viewport dimensions vary between interactive Neovim and headless tests. actual.window, expected.window = nil, nil - assert.same(expected, actual) + snapshot_assertions.assert_same(expected, actual) assert.is_true(replay.replay_full_session()) local full = helpers.output_snapshot(state.windows.output_buf, output_window.namespace, expected_file) full.window = nil - assert.same(expected, full) + snapshot_assertions.assert_same(expected, full) replay.replay_all(0) local reset = helpers.output_snapshot(state.windows.output_buf, output_window.namespace, expected_file) reset.window = nil - assert.same(expected, reset) + snapshot_assertions.assert_same(expected, reset) end) end end) diff --git a/tests/snapshot_assertions.lua b/tests/snapshot_assertions.lua new file mode 100644 index 00000000..ac7485a1 --- /dev/null +++ b/tests/snapshot_assertions.lua @@ -0,0 +1,59 @@ +local assert = require('luassert') + +local M = {} + +local function normalize_json_slashes(line) + if not line:match('^%s*[%[{]') or not pcall(vim.json.decode, line) then + return line + end + -- Older Neovim encoders escape slashes even in single-line tool input dumps. + -- An even backslash run encodes a literal backslash, not an escaped slash. + return ( + line:gsub('(\\+)/', function(backslashes) + return (#backslashes % 2 == 1 and backslashes:sub(2) or backslashes) .. '/' + end) + ) +end + +---@param snapshot table +---@return table +local function normalized(snapshot) + local copy = vim.deepcopy(snapshot) + ---@type string[] + local lines = snapshot.lines + local fence, first_line + for index, line in ipairs(lines) do + if fence then + if line == fence then + local content = table.concat(vim.list_slice(lines, first_line, index - 1), '\n') + local ok, decoded = pcall(vim.json.decode, content) + if ok then + -- Keep line positions so extmark/action comparisons remain exact. + for content_index = first_line, index - 1 do + copy.lines[content_index] = false + end + copy.lines[first_line] = { json = decoded } + end + fence, first_line = nil, nil + end + else + fence = line:match('^(```+)json$') + if fence then + first_line = index + 1 + else + copy.lines[index] = normalize_json_slashes(line) + end + end + end + return copy +end + +---Compare fenced JSON structurally and equivalent inline JSON slash escaping. +---All other snapshot fields remain exact. +---@param expected table +---@param actual table +function M.assert_same(expected, actual) + assert.same(normalized(expected), normalized(actual)) +end + +return M diff --git a/tests/replay/lazy_render_scroll_spec.lua b/tests/unit/lazy_render_scroll_spec.lua similarity index 100% rename from tests/replay/lazy_render_scroll_spec.lua rename to tests/unit/lazy_render_scroll_spec.lua diff --git a/tests/unit/permission_window_spec.lua b/tests/unit/permission_window_spec.lua index 6a7cbe7e..e9c7b35e 100644 --- a/tests/unit/permission_window_spec.lua +++ b/tests/unit/permission_window_spec.lua @@ -88,6 +88,106 @@ describe('permission_window', function() assert.are.equal('', captured_opts.content[2]) end) + it('renders exact command previews alongside tool descriptions, not wildcard patterns', function() + local captured_opts + permission_window._dialog = { + format_dialog = function(_, _, opts) + captured_opts = opts + end, + } + permission_window._permission_queue = { + { + id = 'per_preview', + action = 'bash', + message = 'Inspect changes', + patterns = { 'git *' }, + preview = { title = 'Run command', command = 'git status\ngit diff' }, + }, + } + permission_window.format_display(Output.new()) + assert.is_truthy(captured_opts.content[1]:find('Inspect changes', 1, true)) + assert.same({ '', '```bash', 'git status', 'git diff', '```' }, vim.list_slice(captured_opts.content, 2)) + end) + + it('renders only the full V2 command when description and title are absent', function() + local captured_opts + permission_window._dialog = { + format_dialog = function(_, _, opts) + captured_opts = opts + end, + } + local command = + 'stylua lua/opencode/ui/permission_window.lua && make format-check && make typecheck 2>&1 | tail -30' + permission_window._permission_queue = { + { + id = 'per_shell', + action = 'shell', + resources = { + 'stylua lua/opencode/ui/permission_window.lua', + 'make format-check', + 'make typecheck', + 'tail -30', + }, + preview = { command = command }, + }, + } + permission_window.format_display(Output.new()) + assert.is_truthy(captured_opts.content[1]:find('*shell*', 1, true)) + assert.same({ '', '```shell', command, '```' }, vim.list_slice(captured_opts.content, 2)) + + permission_window._permission_queue[1].resources = { command } + permission_window.format_display(Output.new()) + assert.same({ '', '```shell', command, '```' }, vim.list_slice(captured_opts.content, 2)) + end) + + it('keeps V2 resource patterns as fallback when no preview is available', function() + local captured_opts + permission_window._dialog = { + format_dialog = function(_, _, opts) + captured_opts = opts + end, + } + permission_window._permission_queue = { + { id = 'per_shell', action = 'shell', resources = { 'make format-check', 'make typecheck' } }, + } + permission_window.format_display(Output.new()) + assert.same( + { '```shell', 'make format-check', 'make typecheck', '```', '' }, + vim.list_slice(captured_opts.content, 2) + ) + end) + + it('renders a frozen diff preview with addition and deletion highlights', function() + local captured_opts + permission_window._dialog = { + format_dialog = function(_, _, opts) + captured_opts = opts + end, + } + permission_window._permission_queue = { + { + id = 'per_diff', + permission = 'edit', + preview = { title = 'Edit file', diff = '@@ -1 +1 @@\n-old\n+new\n', path = 'file.lua' }, + }, + } + permission_window.format_display(Output.new()) + local output = Output.new() + captured_opts.render_content(output) + assert.is_truthy(output.lines[1]:find('`Edit file`', 1, true)) + assert.is_truthy(vim.tbl_contains(output.lines, '`````lua')) + assert.is_truthy(table.concat(output.lines, '\n'):find('old', 1, true)) + assert.is_truthy(table.concat(output.lines, '\n'):find('new', 1, true)) + local highlights = {} + for _, extmarks in pairs(output.extmarks) do + for _, extmark in ipairs(extmarks) do + highlights[extmark.hl_group or ''] = true + end + end + assert.is_true(highlights.OpencodeDiffAdd) + assert.is_true(highlights.OpencodeDiffDelete) + end) + it('renders multiple resource patterns from the frozen request', function() local captured_opts = nil permission_window._dialog = { @@ -238,6 +338,36 @@ describe('permission_window', function() assert.are.same({ 'ses_b:per_b' }, replies) end) + it('keeps updated requests unique and advances the queue after replies', function() + local requests = { + per_a = { id = 'per_a', session_id = 'ses_a', status = 'pending', permission = 'bash' }, + per_b = { id = 'per_b', session_id = 'ses_a', status = 'pending', permission = 'edit' }, + } + local replies = {} + local observation = { + read = function() + return { permission_requests_by_id = requests } + end, + reply_permission = function(_, id, answer) + replies[#replies + 1] = { id = id, choice = answer.choice } + requests[id].status = 'answered' + return Promise.new():resolve(true) + end, + } + permission_window.sync({ observation }) + requests.per_a.preview = { command = 'git status' } + permission_window.sync({ observation }) + assert.equals(2, permission_window.get_permission_count()) + assert.equals('git status', permission_window.get_current_permission().preview.command) + permission_window.reply(permission_window.get_current_permission(), 'once'):await() + permission_window.sync({ observation }) + assert.equals('per_b', permission_window.get_current_permission().id) + permission_window.reply(permission_window.get_current_permission(), 'always'):await() + permission_window.sync({ observation }) + assert.equals(0, permission_window.get_permission_count()) + assert.same({ { id = 'per_a', choice = 'once' }, { id = 'per_b', choice = 'always' } }, replies) + end) + describe('interaction lifecycle', function() local state = require('opencode.state') local ui = require('opencode.ui.ui') diff --git a/tests/unit/protocol_v1_legacy_context_spec.lua b/tests/unit/protocol_v1_legacy_context_spec.lua new file mode 100644 index 00000000..f8a008f5 --- /dev/null +++ b/tests/unit/protocol_v1_legacy_context_spec.lua @@ -0,0 +1,188 @@ +local assert = require('luassert') +local normalize = require('opencode.protocols.v1.normalize') +local helpers = require('tests.helpers') +local formatter = require('opencode.ui.formatter') + +---@param payload table +---@return table +local function part(payload) + return { + id = 'prt-context', + sessionID = 'ses-context', + messageID = 'msg-context', + type = 'text', + synthetic = true, + text = vim.json.encode(payload), + } +end + +describe('V1 historical synthetic editor context', function() + it('maps legacy selection JSON to the same facts as declared metadata', function() + local native = part({ + context_type = 'selection', + content = '```lua\nreturn value\n```', + lines = '8, 9', + file = { name = 'main.lua' }, + }) + local original = vim.deepcopy(native) + local legacy, diagnostic = normalize.mapped_content(native) + native.metadata = { context_type = 'selection' } + local current = normalize.mapped_content(native) + + assert.is_nil(diagnostic) + assert.same(current, legacy) + assert.equals('editor_context', legacy.kind) + assert.same({ kind = 'selection', file_name = 'main.lua', range = '8, 9' }, legacy.source) + native.metadata = nil + assert.same(original, native) + end) + + it('preserves cursor excerpts and surrounding lines', function() + local content, diagnostic = normalize.mapped_content(part({ + context_type = 'cursor-data', + line = 59, + column = 1, + line_content = 'local value = true', + lines_before = { 'before' }, + lines_after = { 'after' }, + })) + + assert.is_nil(diagnostic) + assert.equals('editor_context', content.kind) + assert.equals('cursor', content.source.kind) + assert.equals(59, content.line) + assert.equals(1, content.column) + assert.equals('local value = true', content.line_content) + assert.same({ 'before' }, content.lines_before) + assert.same({ 'after' }, content.lines_after) + end) + + it('maps compact diagnostics without requiring metadata', function() + local content, diagnostic = normalize.mapped_content(part({ + context_type = 'diagnostics', + content = { { msg = 'bad value', severity = 2, pos = 'l8:c3' } }, + })) + + assert.is_nil(diagnostic) + assert.equals('diagnostics', content.source.kind) + assert.same({ { message = 'bad value', severity = 2, position = 'l8:c3' } }, content.diagnostics) + end) + + it('renders valid legacy hint diagnostics without dropping or crashing on their severity', function() + local content = normalize.mapped_content(part({ + context_type = 'diagnostics', + content = { { message = 'hint', severity = 4, lnum = 0, col = 0 } }, + })) + local output = require('opencode.ui.output').new() + + formatter._format_diagnostics_context(output, content) + + assert.equals('**Diagnostics:** ' .. require('opencode.ui.icons').get('info') .. '(1)', output.lines[1]) + end) + + it('connects trailing file borders to normalized selection and cursor context', function() + for _, payload in ipairs({ + { context_type = 'selection', content = 'selected code' }, + { context_type = 'cursor-data', line = 1, column = 1, line_content = 'cursor code' }, + }) do + local context = normalize.mapped_content(part(payload)) + local file = { id = 'prt-file', kind = 'file', name = 'main.lua' } + local output = formatter.format_part(file, { id = 'msg-user', kind = 'user', content = { context, file } }, true) + + assert.is_not_nil(output.extmarks[-1]) + end + end) + + it('converts raw Neovim diagnostics to neutral messages and one-based positions', function() + local native = part({ + context_type = 'diagnostics', + file = 'main.lua', + content = { + { message = 'bad value', severity = 2, lnum = 7, col = 2, bufnr = 13 }, + { msg = 'compact entry', severity = 1, pos = 'l9:c1' }, + }, + }) + local original = vim.deepcopy(native) + local content, diagnostic = normalize.mapped_content(native) + + assert.is_nil(diagnostic) + assert.same({ kind = 'diagnostics', file_name = 'main.lua' }, content.source) + assert.same({ + { message = 'bad value', severity = 2, position = 'l8:c3' }, + { message = 'compact entry', severity = 1, position = 'l9:c1' }, + }, content.diagnostics) + assert.same(original, native) + end) + + it('keeps explicit metadata authoritative rather than guessing another context type', function() + local native = part({ context_type = 'selection', content = 'selected code' }) + native.metadata = { context_type = 'cursor-data' } + local content, diagnostic = normalize.mapped_content(native) + + assert.equals('text', content.kind) + assert.equals(native.text, content.text) + assert.matches('invalid cursor%-data editor context JSON', diagnostic) + end) + + it('leaves non-synthetic prompts and ordinary synthetic JSON untouched', function() + local prompt = part({ context_type = 'selection', content = 'not an attachment' }) + prompt.synthetic = false + local ordinary = part({ content = 'ordinary JSON' }) + local unknown = part({ context_type = 'unknown', content = 'ordinary JSON' }) + for _, native in ipairs({ prompt, ordinary, unknown }) do + local content, diagnostic = normalize.mapped_content(native) + assert.equals('text', content.kind) + assert.equals(native.text, content.text) + assert.is_nil(diagnostic) + end + end) + + it('does not reinterpret malformed synthetic JSON', function() + local native = part({}) + native.text = 'Called the Read tool with the following input: {"filePath":"main.lua"}' + local content, diagnostic = normalize.mapped_content(native) + + assert.equals('text', content.kind) + assert.equals(native.text, content.text) + assert.is_nil(diagnostic) + end) + + it('diagnoses invalid declared legacy context without losing its original text', function() + for _, payload in ipairs({ + { context_type = 'selection', content = {} }, + { context_type = 'cursor-data', line = 'invalid', column = 1, line_content = 'text' }, + { context_type = 'diagnostics', content = { { message = 'bad value', severity = 2, lnum = -1, col = 0 } } }, + { + context_type = 'diagnostics', + content = { { msg = 'good', severity = 2, pos = 'l1:c1' }, { message = 'bad', severity = 2 } }, + }, + }) do + local native = part(payload) + local content, diagnostic = normalize.mapped_content(native) + assert.equals('text', content.kind) + assert.equals(native.text, content.text) + assert.is_not_nil(diagnostic) + end + end) + + it('decodes the historical selection, cursor, and diagnostics replay fixtures', function() + local expected = { selection = 2, cursor_data = 1, diagnostics = 1 } + for fixture, count in pairs(expected) do + local decoded = {} + for _, event in ipairs(helpers.load_test_data('tests/data/' .. fixture .. '.json')) do + if event.type == 'message.part.updated' then + local content = normalize.mapped_content(event.properties.part) + if content.kind == 'editor_context' then + decoded[content.id] = content + end + end + end + assert.equals(count, vim.tbl_count(decoded)) + if fixture == 'diagnostics' then + local diagnostics = vim.tbl_values(decoded)[1].diagnostics + assert.equals(3, #diagnostics) + assert.equals('l131:c21', diagnostics[1].position) + end + end + end) +end) diff --git a/tests/unit/protocol_v1_observation_runtime_spec.lua b/tests/unit/protocol_v1_observation_runtime_spec.lua index 4067c388..4effba46 100644 --- a/tests/unit/protocol_v1_observation_runtime_spec.lua +++ b/tests/unit/protocol_v1_observation_runtime_spec.lua @@ -538,6 +538,206 @@ describe('V1 protocol Observation runtime', function() unsubscribe() end) + it('maps historical V1 revert updates without slug only for 0.x records', function() + local connection, server = runtime() + local observation = observe(connection, 'ses-legacy') + local unsubscribe = observation:watch({ 'session' }, function() end) + local revert = { messageID = 'msg-reverted', snapshot = 'snap-1', diff = 'diff' } + + emit(server.streams[1], '/server/project', 'session.updated', { + info = { + id = 'ses-legacy', + projectID = 'project-1', + directory = '/server/project', + title = 'Historical session', + version = '0.15.8', + time = { created = 1, updated = 2 }, + revert = revert, + }, + }) + + local state = observation:read() + assert.equals('current', state.sync.session.state) + assert.equals('Historical session', state.session.title) + assert.same(revert, state.session.revert) + + emit(server.streams[1], '/server/project', 'session.updated', { + sessionID = 'ses-legacy', + info = { + id = 'ses-legacy', + slug = 'current', + projectID = 'project-1', + directory = '/server/project', + title = 'Current session', + version = '1.18.30', + time = { created = 1, updated = 3 }, + revert = revert, + }, + }) + + assert.equals('current', state.sync.session.state) + assert.equals('Current session', state.session.title) + + emit(server.streams[1], '/server/project', 'session.updated', { + sessionID = 'ses-legacy', + info = { + id = 'ses-legacy', + projectID = 'project-1', + directory = '/server/project', + title = 'Missing slug', + version = '1.18.30', + time = { created = 1, updated = 4 }, + revert = revert, + }, + }) + + assert.equals('error', state.sync.session.state) + assert.equals('Current session', state.session.title) + + emit(server.streams[1], '/server/project', 'session.updated', { + sessionID = 'ses-other', + info = { + id = 'ses-legacy', + slug = 'current', + projectID = 'project-1', + directory = '/server/project', + title = 'Mismatched session', + version = '1.18.30', + time = { created = 1, updated = 5 }, + revert = revert, + }, + }) + + assert.equals('error', state.sync.session.state) + assert.equals('Current session', state.session.title) + unsubscribe() + end) + + it( + 'maps historical permissions, enriches linked previews, and preserves terminal replies across refreshes', + function() + local connection, server = runtime() + local observation = observe(connection, 'ses-legacy') + local unsubscribe = observation:watch({ 'messages', 'permissions' }, function() end) + local permission = { + id = 'per-legacy', + sessionID = 'ses-legacy', + type = 'bash', + title = 'Run command', + messageID = 'msg-tool', + callID = 'call-tool', + time = { created = 1 }, + pattern = 'git *', + metadata = { command = 'git status' }, + } + emit(server.streams[1], '/server/project', 'permission.updated', permission) + local request = observation:read().permission_requests_by_id['per-legacy'] + assert.same({ 'git *' }, request.patterns) + assert.same({}, request.always) + assert.equals('git status', request.preview.command) + assert.equals('Run command', request.preview.title) + + emit(server.streams[1], '/server/project', 'message.updated', { + sessionID = 'ses-legacy', + info = { id = 'msg-tool', sessionID = 'ses-legacy', role = 'assistant', time = { created = 1 } }, + }) + local part = { + id = 'prt-tool', + sessionID = 'ses-legacy', + messageID = 'msg-tool', + type = 'tool', + tool = 'bash', + callID = 'call-other', + state = { status = 'running', input = { description = 'Wrong tool', command = 'wrong command' } }, + } + emit(server.streams[1], '/server/project', 'message.part.updated', { sessionID = 'ses-legacy', part = part }) + assert.is_nil(request.message) + assert.equals('git status', request.preview.command) + part.callID = 'call-tool' + part.state.input = { description = 'Inspect changes', command = 'git status --short' } + emit(server.streams[1], '/server/project', 'message.part.updated', { sessionID = 'ses-legacy', part = part }) + assert.equals('Inspect changes', request.message) + assert.equals('git status --short', request.preview.command) + + emit(server.streams[1], '/server/project', 'permission.updated', permission) + request = observation:read().permission_requests_by_id['per-legacy'] + assert.equals('Inspect changes', request.message) + assert.equals('git status --short', request.preview.command) + assert.is_true(observation:reply_permission(request.id, { choice = 'reject', message = 'Do not run' }):await()) + assert.same({ reply = 'reject', message = 'Do not run' }, server.actions[1].answer) + assert.equals('per-legacy', server.actions[1].request_id) + assert.same({ directory = '/server/project' }, server.actions[1].location) + + emit(server.streams[1], '/server/project', 'permission.replied', { + sessionID = 'ses-legacy', + permissionID = 'per-legacy', + response = 'reject', + }) + emit(server.streams[1], '/server/project', 'permission.updated', permission) + assert.equals('answered', observation:read().permission_requests_by_id['per-legacy'].status) + assert.equals('reject', observation:read().permission_requests_by_id['per-legacy'].answer) + emit(server.streams[1], '/server/project', 'permission.replied', { + sessionID = 'ses-legacy', + permissionID = 'per-early', + response = 'once', + }) + permission.id = 'per-early' + emit(server.streams[1], '/server/project', 'permission.updated', permission) + assert.equals('answered', observation:read().permission_requests_by_id['per-early'].status) + server.permissions[1]:resolve({ permission }) + assert.is_true(vim.wait(500, function() + return #server.permissions == 2 + end, 10)) + server.permissions[2]:resolve({ permission }) + assert.is_true(vim.wait(500, function() + return observation:read().sync.permissions.state == 'current' + end, 10)) + assert.equals('answered', observation:read().permission_requests_by_id['per-early'].status) + unsubscribe() + end + ) + + it('accepts historical edits without patterns and rejects malformed legacy and current requests', function() + local connection, server = runtime() + local observation = observe(connection, 'ses-edit') + local unsubscribe = observation:watch({ 'permissions' }, function() end) + local permission = { + id = 'per-edit', + sessionID = 'ses-edit', + type = 'edit', + title = 'Edit file', + messageID = 'msg-tool', + callID = 'call-tool', + time = { created = 1 }, + metadata = { diff = '@@ -1 +1 @@\n-old\n+new\n', filePath = '/server/project/file.lua' }, + } + emit(server.streams[1], '/server/project', 'permission.updated', permission) + local request = observation:read().permission_requests_by_id['per-edit'] + assert.same({}, request.patterns) + assert.equals(permission.metadata.diff, request.preview.diff) + assert.equals('/server/project/file.lua', request.preview.path) + permission.sessionID = 'ses-foreign' + permission.id = 'per-foreign' + emit(server.streams[1], '/server/project', 'permission.updated', permission) + assert.is_nil(observation:read().permission_requests_by_id['per-foreign']) + permission.sessionID = 'ses-edit' + permission.id = 'per-invalid' + permission.metadata.diff = 42 + emit(server.streams[1], '/server/project', 'permission.updated', permission) + assert.equals('error', observation:read().sync.permissions.state) + assert.is_nil(observation:read().permission_requests_by_id['per-invalid']) + permission.metadata.diff = 'valid diff' + permission.pattern = { 42 } + emit(server.streams[1], '/server/project', 'permission.updated', permission) + assert.is_nil(observation:read().permission_requests_by_id['per-invalid']) + permission.pattern = nil + emit(server.streams[1], '/server/project', 'permission.asked', permission) + assert.equals('error', observation:read().sync.permissions.state) + assert.is_nil(observation:read().permission_requests_by_id['per-invalid']) + assert.equals('pending', request.status) + unsubscribe() + end) + it('records missing native resource identities without changing another resource', function() local connection, server = runtime() local observation = observe(connection, 'ses-missing') diff --git a/tests/unit/protocol_v2_observation_runtime_spec.lua b/tests/unit/protocol_v2_observation_runtime_spec.lua index 38098409..5c32d6ca 100644 --- a/tests/unit/protocol_v2_observation_runtime_spec.lua +++ b/tests/unit/protocol_v2_observation_runtime_spec.lua @@ -482,6 +482,252 @@ describe('V2 protocol Observation runtime', function() stop() end) + it('maps optional permission metadata from snapshots and events without inventing missing previews', function() + local value = connection() + local native = { + id = 'per-preview', + sessionID = 'ses-main', + action = 'bash', + resources = { 'git *' }, + message = 'Inspect changes', + metadata = { command = 'git status --short' }, + } + local streams = install_operations(value, { + list_permissions = function() + return resolved({ native }) + end, + }) + local observed = value:observe({ id = 'ses-main' }) + local stop = observed:watch({ 'permissions' }, function() end) + flush(function() + return observed:read().sync.permissions.state == 'current' + end) + local request = observed:read().permission_requests_by_id['per-preview'] + assert.same({ command = 'git status --short' }, request.preview) + assert.equals('Inspect changes', request.message) + assert.same({ 'git *' }, request.resources) + assert.equals('git status --short', native.metadata.command) + + emit( + streams[1], + event('ses-main', 'permission.asked', { + id = 'per-edit', + action = 'edit', + resources = { 'file.lua' }, + metadata = { diff = '@@ -1 +1 @@\n-old\n+new', filePath = 'file.lua' }, + }) + ) + assert.same( + { diff = '@@ -1 +1 @@\n-old\n+new', path = 'file.lua' }, + observed:read().permission_requests_by_id['per-edit'].preview + ) + emit( + streams[1], + event('ses-main', 'permission.asked', { + id = 'per-plain', + action = 'read', + resources = { 'file.lua' }, + }) + ) + assert.is_nil(observed:read().permission_requests_by_id['per-plain'].preview) + local normalize = require('opencode.protocols.v2.normalize') + assert.has_error(function() + normalize.mapped_permission({ + id = 'per-invalid', + sessionID = 'ses-main', + action = 'bash', + resources = {}, + metadata = { command = 42 }, + }) + end) + assert.is_nil(observed:read().permission_requests_by_id['per-invalid']) + assert.has_error(function() + normalize.mapped_permission({ + id = 'per-invalid-source', + sessionID = 'ses-main', + action = 'bash', + resources = {}, + source = { type = 'tool', messageID = 'msg-tool' }, + }) + end) + assert.is_nil(observed:read().permission_requests_by_id['per-invalid-source']) + stop() + end) + + it('fills previews from the exact linked tool and publishes them after online tool input arrives', function() + local value = connection() + local streams = install_operations(value) + local observed = value:observe({ id = 'ses-main' }) + local permission_changes = 0 + local stop = observed:watch({ 'messages', 'permissions' }, function(_, resource) + if resource == 'permissions' then + permission_changes = permission_changes + 1 + end + end) + flush(function() + return observed:read().sync.messages.state == 'current' and observed:read().sync.permissions.state == 'current' + end) + emit( + streams[1], + event('ses-main', 'permission.asked', { + id = 'per-linked', + action = 'bash', + resources = { 'git *' }, + source = { type = 'tool', messageID = 'msg-tool', id = 'tool-right' }, + }) + ) + emit( + streams[1], + event('ses-main', 'permission.asked', { + id = 'per-authoritative', + action = 'bash', + resources = { 'git *' }, + message = 'Server description', + metadata = { command = 'server command' }, + source = { type = 'tool', messageID = 'msg-tool', id = 'tool-right' }, + }) + ) + emit( + streams[1], + event('ses-main', 'session.step.started', { + assistantMessageID = 'msg-tool', + agent = 'build', + model = { providerID = 'p', id = 'm' }, + }) + ) + local function called(id, command) + emit( + streams[1], + event('ses-main', 'session.tool.input.started', { + assistantMessageID = 'msg-tool', + id = id, + name = 'bash', + }) + ) + emit( + streams[1], + event('ses-main', 'session.tool.called', { + assistantMessageID = 'msg-tool', + id = id, + name = 'bash', + input = { command = command, description = 'Inspect changes' }, + }) + ) + end + called('tool-wrong', 'wrong command') + assert.is_nil(observed:read().permission_requests_by_id['per-linked'].preview.command) + local before = permission_changes + called('tool-right', 'git status --short') + local request = observed:read().permission_requests_by_id['per-linked'] + assert.equals('git status --short', request.preview.command) + assert.equals('Inspect changes', request.message) + assert.equals(before + 1, permission_changes) + local authoritative = observed:read().permission_requests_by_id['per-authoritative'] + assert.equals('server command', authoritative.preview.command) + assert.equals('Server description', authoritative.message) + emit(streams[1], event('ses-main', 'permission.replied', { requestID = 'per-linked', reply = 'once' })) + assert.equals('answered', request.status) + stop() + end) + + for _, permissions_first in ipairs({ true, false }) do + it( + 'restores linked edit previews regardless of snapshot arrival order: permissions first ' + .. tostring(permissions_first), + function() + local value = connection() + local permission_page, message_page = Promise.new(), Promise.new() + install_operations(value, { + list_messages = function() + return message_page + end, + list_permissions = function() + return permission_page + end, + }) + local observed = value:observe({ id = 'ses-main' }) + local stop = observed:watch({ 'messages', 'permissions' }, function() end) + local permissions = { + { + id = 'per-edit', + sessionID = 'ses-main', + action = 'edit', + resources = { 'file.lua' }, + message = 'Edit file', + source = { type = 'tool', messageID = 'msg-edit', id = 'tool-edit' }, + }, + { + id = 'per-diff-only', + sessionID = 'ses-main', + action = 'edit', + resources = { 'file.lua' }, + metadata = { diff = 'server diff' }, + source = { type = 'tool', messageID = 'msg-edit', id = 'tool-edit' }, + }, + { + id = 'per-conflict', + sessionID = 'ses-main', + action = 'edit', + resources = { 'other.lua' }, + metadata = { filePath = 'other.lua' }, + source = { type = 'tool', messageID = 'msg-edit', id = 'tool-edit' }, + }, + } + local page = { + data = { + { + id = 'msg-edit', + type = 'assistant', + agent = 'build', + time = { created = 1 }, + content = { + { + type = 'tool', + id = 'tool-edit', + name = 'edit', + time = { created = 1 }, + state = { + status = 'running', + input = { filePath = 'file.lua' }, + metadata = { diff = '@@ -1 +1 @@\n-old\n+new' }, + }, + }, + }, + }, + }, + cursor = {}, + } + if permissions_first then + permission_page:resolve(permissions) + flush(function() + return observed:read().sync.permissions.state == 'current' + end) + message_page:resolve(page) + else + message_page:resolve(page) + flush(function() + return observed:read().sync.messages.state == 'current' + end) + permission_page:resolve(permissions) + end + flush(function() + return observed:read().sync.messages.state == 'current' + and observed:read().sync.permissions.state == 'current' + end) + local preview = observed:read().permission_requests_by_id['per-edit'].preview + assert.equals('@@ -1 +1 @@\n-old\n+new', preview.diff) + assert.equals('file.lua', preview.path) + local diff_only = observed:read().permission_requests_by_id['per-diff-only'].preview + assert.equals('server diff', diff_only.diff) + assert.equals('file.lua', diff_only.path) + local conflict = observed:read().permission_requests_by_id['per-conflict'].preview + assert.equals('other.lua', conflict.path) + assert.is_nil(conflict.diff) + stop() + end + ) + end + it('marks a known inbox item not_pending when an authority snapshot omits it', function() local value = connection() local inbox_page = Promise.new() diff --git a/tests/unit/queued_message_spec.lua b/tests/unit/queued_message_spec.lua new file mode 100644 index 00000000..67a13622 --- /dev/null +++ b/tests/unit/queued_message_spec.lua @@ -0,0 +1,141 @@ +local assert = require('luassert') +local config = require('opencode.config') +local helpers = require('tests.helpers') +local replay = require('tests.manual.renderer_replay') +local state = require('opencode.state') +local ui = require('opencode.ui.ui') + +describe('V1 queued message projection and display', function() + local original_show_ids + + local function queued() + return assert(state.session.active_observation()):read().entries_by_id.msg_queued.queued + end + + local function has_badge() + local marks = vim.api.nvim_buf_get_extmarks( + state.windows.output_buf, + require('opencode.ui.output_window').namespace, + 0, + -1, + { details = true } + ) + for _, mark in ipairs(marks) do + for _, chunk in ipairs(mark[4].virt_text or {}) do + if chunk[1] == ' QUEUED' and chunk[2] == 'OpencodeQueued' then + return true + end + end + end + return false + end + + before_each(function() + original_show_ids = config.debug.show_ids + config.debug.show_ids = true + helpers.replay_setup() + assert.is_true(replay.load_events('tests/data/queue.json')) + end) + + after_each(function() + config.debug.show_ids = original_show_ids + ui.close_windows(state.windows) + end) + + it('projects and displays a queued user arriving while the session is busy', function() + replay.replay_all(0) + + assert.is_true(queued()) + assert.is_true(has_badge()) + end) + + it('keeps the queue fact through metadata updates and idle transitions', function() + replay.replay_all(0) + helpers.replay_event({ + type = 'session.status', + properties = { sessionID = 'ses_queue', status = { type = 'idle' } }, + }) + helpers.replay_event(replay.events[6]) + + assert.is_true(queued()) + assert.is_true(has_badge()) + end) + + it('clears the queue fact and rerenders its header when an assistant consumes that user', function() + replay.replay_all(0) + helpers.replay_event({ + type = 'message.updated', + properties = { + info = { + id = 'msg_reply', + sessionID = 'ses_queue', + role = 'assistant', + parentID = 'msg_queued', + time = { created = 1788528293000 }, + }, + }, + }) + + assert.is_nil(queued()) + assert.is_false(has_badge()) + assert.equals( + 'This message is queued', + assert(state.session.active_observation()):read().entries_by_id.msg_queued.content[1].text + ) + end) + + it('does not mark new user messages queued in an idle session', function() + helpers.replay_events({ replay.events[1], replay.events[2], replay.events[6], replay.events[7] }) + + assert.is_false(queued()) + assert.is_false(has_badge()) + end) + + it('does not clear queued input when an assistant replies to a different user', function() + replay.replay_all(0) + helpers.replay_event(replay.events[3]) + + assert.is_true(queued()) + assert.is_true(has_badge()) + end) + + it('marks user input arriving during a retry as queued', function() + helpers.replay_events({ + replay.events[1], + { + type = 'session.status', + properties = { + sessionID = 'ses_queue', + status = { type = 'retry', attempt = 1, message = 'Retry', next = 1788528294000 }, + }, + }, + replay.events[6], + replay.events[7], + }) + + assert.is_true(queued()) + assert.is_true(has_badge()) + end) + + it('preserves known queue state when a message snapshot is refreshed', function() + replay.replay_all(0) + local observation = assert(state.session.active_observation()) + require('opencode.protocols.v1.observation').ingest_snapshot(observation, { + { info = replay.events[6].properties.info, parts = { replay.events[7].properties.part } }, + }) + + assert.is_true(queued()) + end) + + it('clears a known queue fact when a refreshed snapshot contains its assistant reply', function() + replay.replay_all(0) + local reply = vim.deepcopy(replay.events[3].properties.info) + reply.id, reply.parentID = 'msg_reply', 'msg_queued' + require('opencode.protocols.v1.observation').ingest_snapshot(assert(state.session.active_observation()), { + { info = replay.events[6].properties.info, parts = { replay.events[7].properties.part } }, + { info = reply, parts = {} }, + }) + + assert.is_nil(queued()) + end) +end) diff --git a/tests/replay/renderer_spec.lua b/tests/unit/renderer_observation_spec.lua similarity index 100% rename from tests/replay/renderer_spec.lua rename to tests/unit/renderer_observation_spec.lua diff --git a/tests/unit/renderer_reconciliation_spec.lua b/tests/unit/renderer_reconciliation_spec.lua index ae0bc255..5fb9d9ff 100644 --- a/tests/unit/renderer_reconciliation_spec.lua +++ b/tests/unit/renderer_reconciliation_spec.lua @@ -10,6 +10,7 @@ local spy = require('luassert.spy') describe('renderer incremental reconciliation', function() local observed, observation, changed, controllers, writes, markdown, dirty_part, dirty_message, max_messages, throttle_ms, collapsing, defer_stub, files_stub + local schedule_stub local function notify(resource) changed(observation, resource) @@ -89,6 +90,10 @@ describe('renderer incremental reconciliation', function() end) after_each(function() + if schedule_stub then + schedule_stub:revert() + schedule_stub = nil + end config.ui.output.max_messages = max_messages config.ui.output.rendering.event_throttle_ms = throttle_ms config.ui.output.rendering.event_collapsing = collapsing @@ -119,6 +124,24 @@ describe('renderer incremental reconciliation', function() contexts.current().lazy_render_count = math.huge output_window.clear() writes = spy.on(output_window, 'set_lines') + -- This checks batching and ranges, not how fast a CI host renders history. + local callbacks = {} + local next_callback = 1 + local function schedule(callback) + callbacks[#callbacks + 1] = callback + end + schedule_stub = stub(vim, 'schedule').invokes(schedule) + defer_stub = stub(vim, 'defer_fn').invokes(schedule) + local function reconcile_messages() + changed(observation, 'messages') + while next_callback <= #callbacks do + local callback = callbacks[next_callback] + next_callback = next_callback + 1 + callback() + end + assert.is_false(contexts.current().reconcile_scheduled) + assert.is_false(contexts.current().flush_scheduled) + end observed.entry_order = {} observed.entries_by_id = {} for index = 1, 40 do @@ -135,7 +158,7 @@ describe('renderer incremental reconciliation', function() }, } end - notify('messages') + reconcile_messages() assert.spy(writes).was_called(1) local lines = vim.api.nvim_buf_get_lines(state.windows.output_buf, 0, -1, false) for index = 1, 40 do @@ -148,7 +171,7 @@ describe('renderer incremental reconciliation', function() assert.is_true(first.line_end < tail.line_start) end assert.is_false(contexts.current().bulk_mode) - notify('messages') + reconcile_messages() assert.spy(writes).was_called(1) end) diff --git a/tests/unit/replay_batch_spec.lua b/tests/unit/replay_batch_spec.lua new file mode 100644 index 00000000..e17b25a3 --- /dev/null +++ b/tests/unit/replay_batch_spec.lua @@ -0,0 +1,169 @@ +local assert = require('luassert') +local stub = require('luassert.stub') +local helpers = require('tests.helpers') +local replay = require('tests.manual.renderer_replay') +local state = require('opencode.state') +local ui = require('opencode.ui.ui') +local output_window = require('opencode.ui.output_window') +local config = require('opencode.config') + +describe('replay event batching', function() + local original_show_ids + local wait_stub + local single_event_stub + + before_each(function() + original_show_ids = config.debug.show_ids + config.debug.show_ids = true + helpers.replay_setup() + assert.is_true(replay.load_events('tests/data/simple-session.json')) + helpers.wait_for_replay_ready() + end) + + after_each(function() + helpers.restore_replay_environment() + if wait_stub then + wait_stub:revert() + wait_stub = nil + end + if single_event_stub then + single_event_stub:revert() + single_event_stub = nil + end + config.debug.show_ids = original_show_ids + ui.close_windows(state.windows) + end) + + it('waits once for a native event batch and preserves its final snapshot', function() + local wait = vim.wait + wait_stub = stub(vim, 'wait').invokes(wait) + + helpers.replay_events(replay.events) + + assert.stub(wait_stub).was_called(1) + local expected_file = 'tests/data/simple-session.expected.json' + local expected = helpers.load_test_data(expected_file) + local actual = helpers.output_snapshot(state.windows.output_buf, output_window.namespace, expected_file) + expected.window, actual.window = nil, nil + assert.same(expected, actual) + end) + + it('batches multi-step playback rather than calling the single-event helper', function() + single_event_stub = stub(helpers, 'replay_event') + + replay.replay_next(#replay.events) + + assert.stub(single_event_stub).was_not_called() + assert.equals(#replay.events, replay.event_index) + assert.is_true(replay.wait_for_idle()) + assert.is_true(#vim.api.nvim_buf_get_lines(state.windows.output_buf, 0, -1, false) > 1) + end) + + it('retains the settling single-event helper for interactive stepping', function() + single_event_stub = stub(helpers, 'replay_event') + + replay.replay_next(1) + + assert.stub(single_event_stub).was_called_with(replay.events[1]) + assert.equals(1, replay.event_index) + end) + + it('keeps events after a session directory update inside the same batch', function() + helpers.replay_setup() + assert.is_true(replay.load_events('tests/data/question-multiple-choices.json')) + + replay.replay_all(0) + + local observed = assert(state.session.active_observation()):read() + local user = observed.entries_by_id['msg_f5bf094d40010EarsFjD5WvW47'] + assert.is_not_nil(user) + assert.equals('user', user.kind) + assert.is_truthy( + table + .concat(vim.api.nvim_buf_get_lines(state.windows.output_buf, 0, -1, false), '\n') + :find('Can you ask me a question', 1, true) + ) + end) + + it('bootstraps child tools delivered before the child observation subscribes', function() + assert.is_true(replay.load_events('tests/data/explore.json')) + helpers.wait_for_replay_ready() + local session = require('opencode.ui.renderer.ctx').current().render_session + local child_id = 'ses_341f3e676ffez6WUF6zpok7dUZ' + assert.is_nil(session:child(child_id)) + local connection = state.opencode_server + local root_id = state.active_session.id + assert.same({}, connection.operations.list_children(connection, root_id):wait()) + assert.same({}, connection.operations.list_messages(connection, child_id):wait()) + + replay.replay_all(0) + + local observed = assert(session:child(child_id)):read() + assert.equals('current', observed.sync.messages.state) + assert.equals(11, #observed.entry_order) + local tools = 0 + for _, entry in pairs(observed.entries_by_id) do + for _, content in ipairs(entry.content) do + if content.kind == 'tool' then + tools = tools + 1 + assert.equals('completed', content.state) + end + end + end + assert.equals(18, tools) + local lines = vim.api.nvim_buf_get_lines(state.windows.output_buf, 0, -1, false) + local summaries = 0 + for _, line in ipairs(lines) do + if line:match('^ %*%*') then + summaries = summaries + 1 + end + end + assert.equals(18, summaries) + end) + + it('rehydrates an unidentified early delta only from an authoritative part update', function() + assert.is_true(replay.load_events('tests/data/part-before-message-delta.json')) + replay.replay_next(#replay.events - 1) + + local observation = assert(state.session.active_observation()) + local assistant_id = 'msg_0000000000002' + assert.same({}, observation:read().entries_by_id[assistant_id].content) + + replay.replay_next(1) + + local content = observation:read().entries_by_id[assistant_id].content + assert.equals(1, #content) + assert.equals('text', content[1].kind) + assert.equals('Sure, I can help with that.', content[1].text) + end) + + it('keeps explicit empty tool input authoritative in replay bootstrap snapshots', function() + assert.is_true(replay.load_events('tests/data/mcp-tool.json')) + local final_update = vim.deepcopy(replay.events[7]) + final_update.properties.part.state.input = {} + local events = {} + for index = 1, 6 do + events[index] = replay.events[index] + end + events[7] = final_update + helpers.replay_events(events) + + local connection = state.opencode_server + local messages = connection.operations.list_messages(connection, 'ses_mcp_test_1'):wait() + local tool + for _, message in ipairs(messages) do + for _, part in ipairs(message.parts) do + if part.id == 'prt_mcp_tool1' then + tool = part + end + end + end + assert.same({}, assert(tool).state.input) + assert.equals('completed', tool.state.status) + + local entries = helpers.load_session_from_events(events) + local content = entries[2].content + assert.equals('prt_mcp_tool1', content[2].id) + assert.same({}, content[2].input) + end) +end) diff --git a/tests/unit/replay_environment_spec.lua b/tests/unit/replay_environment_spec.lua new file mode 100644 index 00000000..941a30f4 --- /dev/null +++ b/tests/unit/replay_environment_spec.lua @@ -0,0 +1,51 @@ +local assert = require('luassert') +local stub = require('luassert.stub') +local helpers = require('tests.helpers') +local reference_facts = require('opencode.ui.reference_facts') + +describe('replay environment isolation', function() + local path_stub + local files_stub + + before_each(function() + path_stub = stub(vim.fn, 'fnamemodify').invokes(function(path, modifiers) + return 'host-dependent:' .. modifiers .. ':' .. path + end) + files_stub = stub(reference_facts, 'available_files').returns({ '/host/loaded/file.lua' }) + end) + + after_each(function() + helpers.restore_replay_environment() + path_stub:revert() + files_stub:revert() + end) + + it('uses mock cwd without shortening recorded paths against host cwd or home', function() + helpers.isolate_replay_environment() + + assert.equals('src/test.lua', vim.fn.fnamemodify(helpers.MOCK_CWD .. '/src/test.lua', ':~:.')) + assert.equals('/home/francis/project/test.lua', vim.fn.fnamemodify('/home/francis/project/test.lua', ':~:.')) + assert.equals('test.lua', vim.fn.fnamemodify('test.lua', ':~:.')) + assert.equals('host-dependent::e:test.lua', vim.fn.fnamemodify('test.lua', ':e')) + end) + + it('does not infer reference availability from host files or loaded buffers', function() + helpers.isolate_replay_environment() + + assert.same({}, reference_facts.available_files()) + assert.stub(files_stub).was_not_called() + end) + + it('restores host functions even after repeated isolation', function() + local fnamemodify = vim.fn.fnamemodify + local available_files = reference_facts.available_files + helpers.isolate_replay_environment() + helpers.isolate_replay_environment() + + helpers.restore_replay_environment() + + assert.equals(fnamemodify, vim.fn.fnamemodify) + assert.equals(available_files, reference_facts.available_files) + assert.same({ '/host/loaded/file.lua' }, reference_facts.available_files()) + end) +end) diff --git a/tests/replay/replay_startup_spec.lua b/tests/unit/replay_startup_spec.lua similarity index 95% rename from tests/replay/replay_startup_spec.lua rename to tests/unit/replay_startup_spec.lua index 8ed36244..330b8cf8 100644 --- a/tests/replay/replay_startup_spec.lua +++ b/tests/unit/replay_startup_spec.lua @@ -75,7 +75,8 @@ describe('manual replay startup', function() local result = vim.system(command, { text = true }):wait(10000) assert.equals(0, result.code, result.stderr) - assert.is_truthy(result.stderr:find('REPLAY_ISOLATION_OK', 1, true), result.stderr) + local output = result.stdout .. result.stderr + assert.is_truthy(output:find('REPLAY_ISOLATION_OK', 1, true), output) end) it('keeps the mock connection after startup callbacks and replays V2 JSON without service discovery', function() @@ -123,6 +124,7 @@ describe('manual replay startup', function() local result = vim.system(command, { text = true }):wait(10000) assert.equals(0, result.code, result.stderr) - assert.is_truthy(result.stderr:find('REPLAY_STARTUP_OK', 1, true), result.stderr) + local output = result.stdout .. result.stderr + assert.is_truthy(output:find('REPLAY_STARTUP_OK', 1, true), output) end) end) diff --git a/tests/unit/snapshot_assertions_spec.lua b/tests/unit/snapshot_assertions_spec.lua new file mode 100644 index 00000000..3ede9c69 --- /dev/null +++ b/tests/unit/snapshot_assertions_spec.lua @@ -0,0 +1,95 @@ +local assert = require('luassert') +local snapshots = require('tests.snapshot_assertions') + +---@param json string +---@return table +local function snapshot(json) + return { lines = { 'Input', '`````json', json, '`````' }, extmarks = {}, actions = {} } +end + +describe('snapshot JSON comparison', function() + it('ignores object key order, including nested objects, without changing snapshots', function() + local expected = snapshot('{"error":"bad","tool":{"a":1,"b":2}}') + local actual = snapshot('{"tool":{"b":2,"a":1},"error":"bad"}') + local original = vim.deepcopy(actual) + + snapshots.assert_same(expected, actual) + + assert.same(original, actual) + end) + + it('rejects changed values', function() + assert.has_error(function() + snapshots.assert_same(snapshot('{"a":1}'), snapshot('{"a":2}')) + end) + end) + + it('ignores equivalent JSON slash escaping across Neovim versions', function() + snapshots.assert_same(snapshot('{"path":"a\\/b"}'), snapshot('{"path":"a/b"}')) + end) + + it('preserves array order', function() + assert.has_error(function() + snapshots.assert_same(snapshot('[1,2]'), snapshot('[2,1]')) + end) + end) + + it('keeps JSON-looking text outside JSON fences exact', function() + assert.has_error(function() + snapshots.assert_same({ lines = { '{"a":1,"b":2}' } }, { lines = { '{"b":2,"a":1}' } }) + end) + end) + + it('ignores equivalent slash escaping in unfenced JSON without ignoring key order', function() + snapshots.assert_same( + { lines = { '{"directory":"/project/worktrees/missing"}' } }, + { lines = { '{"directory":"\\/project\\/worktrees\\/missing"}' } } + ) + assert.has_error(function() + snapshots.assert_same({ lines = { '{"path":"a/b"}' } }, { lines = { '{"path":"a/c"}' } }) + end) + end) + + it('preserves literal backslashes before slashes in unfenced JSON', function() + snapshots.assert_same({ lines = { [[{"path":"a\\/b"}]] } }, { lines = { [[{"path":"a\\\/b"}]] } }) + assert.has_error(function() + snapshots.assert_same({ lines = { [[{"path":"a\\/b"}]] } }, { lines = { [[{"path":"a/b"}]] } }) + end) + assert.has_error(function() + snapshots.assert_same({ lines = { [[not JSON \/]] } }, { lines = { 'not JSON /' } }) + end) + end) + + it('keeps malformed JSON exact and distinct from decoded strings', function() + snapshots.assert_same(snapshot('not JSON'), snapshot('not JSON')) + assert.has_error(function() + snapshots.assert_same(snapshot('"text"'), snapshot('text')) + end) + end) + + it('compares multiline JSON objects structurally', function() + snapshots.assert_same( + { lines = { '```json', '{', '"a":1,"b":2', '}', '```' } }, + { lines = { '```json', '{', '"b":2,"a":1', '}', '```' } } + ) + end) + + it('retains exact extmark, action, and line-position comparisons', function() + local expected = snapshot('{"a":1,"b":2}') + local actual = snapshot('{"b":2,"a":1}') + actual.extmarks = { { 1, 2, 0, {} } } + assert.has_error(function() + snapshots.assert_same(expected, actual) + end) + actual.extmarks = {} + actual.actions = { { type = 'changed' } } + assert.has_error(function() + snapshots.assert_same(expected, actual) + end) + actual.actions = {} + table.insert(actual.lines, 3, '') + assert.has_error(function() + snapshots.assert_same(expected, actual) + end) + end) +end) diff --git a/tests/replay/todowrite_malformed_session_spec.lua b/tests/unit/todowrite_malformed_session_spec.lua similarity index 100% rename from tests/replay/todowrite_malformed_session_spec.lua rename to tests/unit/todowrite_malformed_session_spec.lua diff --git a/tests/replay/user_message_metadata_scroll_spec.lua b/tests/unit/user_message_metadata_scroll_spec.lua similarity index 100% rename from tests/replay/user_message_metadata_scroll_spec.lua rename to tests/unit/user_message_metadata_scroll_spec.lua diff --git a/tests/unit/v2_event_capture_spec.lua b/tests/unit/v2_event_capture_spec.lua new file mode 100644 index 00000000..0356e082 --- /dev/null +++ b/tests/unit/v2_event_capture_spec.lua @@ -0,0 +1,103 @@ +local assert = require('luassert') +local config = require('opencode.config') +local event_capture = require('opencode.event_capture') +local helpers = require('tests.helpers') +local replay = require('tests.manual.renderer_replay') +local state = require('opencode.state') +local ui = require('opencode.ui.ui') + +describe('V2 streamed event capture replay', function() + local original_capture + local capture_file + + before_each(function() + original_capture = config.debug.capture_streamed_events + helpers.replay_setup() + event_capture.clear() + config.debug.capture_streamed_events = true + end) + + after_each(function() + config.debug.capture_streamed_events = original_capture + event_capture.clear() + if capture_file then + vim.fn.delete(capture_file) + capture_file = nil + end + if state.active_session then + state.session.clear_active() + end + if state.opencode_server then + state.opencode_server:close() + state.jobs.clear_server() + end + if state.windows then + ui.close_windows(state.windows) + end + end) + + it('saves native event timestamps and replays through V2 observation', function() + local session_id = 'ses-v2-capture-replay' + local message_id = 'msg-v2-capture-replay' + local connection = helpers.new_v2_replay_connection() + state.opencode_server:close() + state.jobs.set_server(connection) + state.session.set_active({ id = session_id, location = { directory = helpers.MOCK_CWD } }) + helpers.wait_for_replay_ready() + + local native_events = { + { + id = 'evt-step', + type = 'session.step.started', + created = 100, + data = { + sessionID = session_id, + assistantMessageID = message_id, + agent = 'build', + model = { providerID = 'provider', id = 'model' }, + }, + }, + { + id = 'evt-text-started', + type = 'session.text.started', + created = 101, + data = { sessionID = session_id, assistantMessageID = message_id, ordinal = 0 }, + }, + { + id = 'evt-text-delta', + type = 'session.text.delta', + created = 102, + data = { sessionID = session_id, assistantMessageID = message_id, ordinal = 0, delta = 'Captured ' }, + }, + { + id = 'evt-text-ended', + type = 'session.text.ended', + created = 103, + data = { sessionID = session_id, assistantMessageID = message_id, ordinal = 0, text = 'Captured V2 output' }, + }, + } + for _, event in ipairs(native_events) do + helpers._replay_stream.on_chunk('data: ' .. vim.json.encode(event) .. '\n\n') + end + + local captured = event_capture.get() + assert.equals(#native_events, #captured) + assert.equals('v2', captured[1].protocol) + assert.equals(native_events[1].created, captured[1].created) + assert.same(native_events[1].data, captured[1].data) + + capture_file = vim.fn.tempname() .. '.json' + assert.equals(#native_events, event_capture.save(capture_file)) + config.debug.capture_streamed_events = false + + assert.is_true(replay.load_events(capture_file)) + assert.equals('v2', state.opencode_server.protocol) + replay.replay_all(0) + assert.is_true(replay.wait_for_idle()) + + local lines = vim.api.nvim_buf_get_lines(state.windows.output_buf, 0, -1, false) + assert.is_truthy(table.concat(lines, '\n'):find('Captured V2 output', 1, true)) + local entries = helpers.load_session_from_events(replay.events) + assert.equals('Captured V2 output', entries[1].content[1].text) + end) +end)