From f6dea477273f62495132c6d1bce965946cdfe726 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 2 Oct 2026 11:33:30 -0400 Subject: [PATCH 1/8] test: restore and streamline JSON snapshot replays Separate replay snapshots from behavior specs, batch native event playback, and stream fixture progress. Compare fenced JSON structurally while keeping other snapshot data exact. Refresh reviewed display-only expectations and document remaining fixture defects and compatibility regressions. --- README.md | 2 +- run_tests.sh | 19 +- tests/data/message-removal.expected.json | 4 +- .../multiple-messages-synthetic.expected.json | 6 +- ...tiple-question-ask-reply-all.expected.json | 2 +- .../data/multiple-question-ask.expected.json | 2 +- .../permission-ask-new-deny.expected.json | 2 +- .../provider-overloaded-status.expected.json | 2 +- tests/data/question-ask-other.expected.json | 2 +- tests/data/question-ask-replied.expected.json | 2 +- tests/data/question-ask.expected.json | 2 +- ...on-multiple-choices-answered.expected.json | 2 +- .../question-multiple-choices.expected.json | 2 +- .../question-multiple-other.expected.json | 2 +- tests/data/redo-all.expected.json | 10 +- tests/data/tool-invalid.expected.json | 239 +++++++++++++++++- tests/data/v2/README.md | 2 +- tests/helpers.lua | 14 +- tests/manual/QUICKSTART.md | 2 +- tests/manual/renderer_replay.lua | 10 +- tests/replay/README.md | 41 +++ tests/replay/SNAPSHOT_TRIAGE.md | 75 ++++++ ...apshot_spec.lua => json_snapshot_spec.lua} | 57 +++-- tests/snapshot_assertions.lua | 43 ++++ .../lazy_render_scroll_spec.lua | 0 .../renderer_observation_spec.lua} | 0 tests/unit/replay_batch_spec.lua | 86 +++++++ .../{replay => unit}/replay_startup_spec.lua | 0 tests/unit/snapshot_assertions_spec.lua | 75 ++++++ .../todowrite_malformed_session_spec.lua | 0 .../user_message_metadata_scroll_spec.lua | 0 31 files changed, 638 insertions(+), 67 deletions(-) create mode 100644 tests/replay/README.md create mode 100644 tests/replay/SNAPSHOT_TRIAGE.md rename tests/replay/{formatter_snapshot_spec.lua => json_snapshot_spec.lua} (51%) create mode 100644 tests/snapshot_assertions.lua rename tests/{replay => unit}/lazy_render_scroll_spec.lua (100%) rename tests/{replay/renderer_spec.lua => unit/renderer_observation_spec.lua} (100%) create mode 100644 tests/unit/replay_batch_spec.lua rename tests/{replay => unit}/replay_startup_spec.lua (100%) create mode 100644 tests/unit/snapshot_assertions_spec.lua rename tests/{replay => unit}/todowrite_malformed_session_spec.lua (100%) rename tests/{replay => unit}/user_message_metadata_scroll_spec.lua (100%) diff --git a/README.md b/README.md index 2abb232c6..ff2936d2a 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/run_tests.sh b/run_tests.sh index f9478db6e..823fdd770 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/tests/data/message-removal.expected.json b/tests/data/message-removal.expected.json index 45a36f2f1..924265290 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 ee8f0c0b4..3d2862a8d 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 ce2a724b3..aa16e89d7 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 992385f71..0a1368eca 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/permission-ask-new-deny.expected.json b/tests/data/permission-ask-new-deny.expected.json index b85b9e6fc..50ea4504a 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/provider-overloaded-status.expected.json b/tests/data/provider-overloaded-status.expected.json index 19b5a44b0..c6c8015d4 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 c25effadd..49a37a6e1 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 808de14ff..abdcc0e24 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 627e0d0c6..6b23688d0 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 de0121c33..3bdaa57bd 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 6ed059745..2780459f3 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 163186240..4d6d37d95 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/redo-all.expected.json b/tests/data/redo-all.expected.json index 285d83386..189892415 100644 --- a/tests/data/redo-all.expected.json +++ b/tests/data/redo-all.expected.json @@ -1949,7 +1949,7 @@ "", "I'll append a single word (\"again\") to the first line of `test.txt`. Applying a precise edit to the existing line now.", "", - "** edit** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", + "** edit** `test.txt`", "", "`````txt", " tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more", @@ -1976,14 +1976,14 @@ "", "I'll read `test.txt` to get the current first-line content, then append one word (\"again2\") to that line. Proceeding to read the file.", "", - "** read** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", + "** read** `test.txt`", "", "----", "", "", "Now I'll append the word \"again2\" to the first line. I'll apply an exact in-place edit to update that line.", "", - "** edit** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", + "** edit** `test.txt`", "", "`````txt", " tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more again", @@ -2010,14 +2010,14 @@ "", "I'll read `test.txt` to get the current first-line content, then append the word `again3`. Proceeding to read the file.", "", - "** read** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", + "** read** `test.txt`", "", "----", "", "", "I'll append the word \"again3\" to the first line of `test.txt` with an exact in-place edit. Applying the change now.", "", - "** edit** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", + "** edit** `test.txt`", "", "`````txt", " tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more again again2", diff --git a/tests/data/tool-invalid.expected.json b/tests/data/tool-invalid.expected.json index e5a094159..99384fac7 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 4a0831716..cd8ee0dfa 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 bca32af47..d1fccd541 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -467,7 +467,7 @@ function M.wait_for_replay_ready() end end -function M.replay_event(event) +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,8 +494,9 @@ 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 + -- 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) @@ -504,6 +505,10 @@ function M.replay_event(event) directory = event.directory or directory, payload = { type = event.type, properties = properties }, }) .. '\n\n') +end + +function M.replay_event(event) + dispatch_replay_event(event) local rendered = false vim.schedule(function() rendered = true @@ -519,8 +524,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 adbd307fc..bb5e3b9fc 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/renderer_replay.lua b/tests/manual/renderer_replay.lua index 1bac4ceb9..9dc3c9442 100644 --- a/tests/manual/renderer_replay.lua +++ b/tests/manual/renderer_replay.lua @@ -129,15 +129,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() diff --git a/tests/replay/README.md b/tests/replay/README.md new file mode 100644 index 000000000..c66d3f31b --- /dev/null +++ b/tests/replay/README.md @@ -0,0 +1,41 @@ +# 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. + +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. + +## 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/SNAPSHOT_TRIAGE.md b/tests/replay/SNAPSHOT_TRIAGE.md new file mode 100644 index 000000000..a11f99cff --- /dev/null +++ b/tests/replay/SNAPSHOT_TRIAGE.md @@ -0,0 +1,75 @@ +# Restored V1 snapshot triage + +The restored suite initially had 30 failures after batching and ignoring legacy +full-session viewport metadata. These failures are not all stale snapshots. + +## Fixes made during triage + +- **Replay harness bug:** V1 envelopes used the UI's active-session directory. + After a valid `session.updated`, observation facts had the new directory while + UI state still had the old directory. Later events in the batch were dropped. + Envelopes now use the observation's current session location. A regression test + covers `question-multiple-choices`. +- **12 reviewed header updates:** only agent/model header text changed. Buffer + lines, actions, extmark positions, and non-header extmark properties matched. + No missing-content expectations were removed. + +## Regeneration-only changes + +| Fixture | Evidence | Status | +| --- | --- | --- | +| `message-removal` | Assistant facts have no agent/mode; current header correctly says `ASSISTANT`, not invented `BUILD`. | Updated | +| `multiple-messages-synthetic` | Same missing-agent fallback; no content/action changes. | Updated | +| `provider-overloaded-status` | User model is supplied by the fixture and now appears in its header. | Updated | +| `permission-ask-new-deny` | Model header only; denial output/actions unchanged. | Updated | +| `question-ask` | Model header only; question output/actions unchanged. | Updated | +| `question-ask-other` | Model header only; question output/actions unchanged. | Updated | +| `question-ask-replied` | Model header only; answer output/actions unchanged. | Updated | +| `multiple-question-ask` | Model header only; stacked questions/actions unchanged. | Updated | +| `multiple-question-ask-reply-all` | Model header only; answered questions/actions unchanged. | Updated | +| `question-multiple-choices` | After fixing replay directory routing, only model header differs. | Updated | +| `question-multiple-choices-answered` | After fixing replay directory routing, only model header differs. | Updated | +| `question-multiple-other` | After fixing replay directory routing, only model header differs. | Updated | +| `redo-all` | Buffer diff contains only absolute-to-relative tool paths; actions match. | Updated | +| `tool-invalid` | Generic tool formatter adds Input/Result sections; existing error remains. | Regenerated | + +## Bugs or compatibility regressions: do not regenerate missing content + +| Fixture | Evidence / next fix | +| --- | --- | +| `queue` | Expected `QUEUED` header badge disappears. Its highlight still exists, but no formatter emits the badge. Restore queued-message projection/display; do not classify this as an agent-label change. | +| `selection` | Historical synthetic text carries `context_type` inside JSON, without `metadata.context_type`. V1 normalizer only recognizes the metadata form, so selection content disappears. Decide/support legacy persisted-context decoding. | +| `cursor_data` | Same legacy-context encoding; cursor excerpt disappears. | +| `diagnostics` | Same legacy-context encoding; diagnostic summary disappears. Historical diagnostic item fields also differ from current decoder contract. | +| `permission-ask-new` | Missing diagnostics from legacy context, plus model header change. Fix context interpretation before updating cosmetic differences. | +| `permission-ask-new-approve` | Same missing legacy diagnostics; do not regenerate them away. | +| `permission-prompt` | Capture uses historical `permission.updated` (`type`, `pattern`, `callID`), whereas current adapter accepts the newer permission contract. Missing prompt is a compatibility gap, not cosmetic drift. | +| `shifting-and-multiple-perms` | Same unsupported historical permission events remove all prompts; added reference icon is independently cosmetic. | +| `revert` | Historical `session.updated` lacks fields required by current `session_shape` (notably `slug`). Revert facts never reach rendering, so reverted messages remain visible. Resolve wire-version compatibility before changing expected output. | +| `redo-once` | Same rejected historical session updates; revert summary disappears and reverted conversation remains visible. Relative tool paths are independently cosmetic. | + +Compatibility regressions above reproduce with the historical wire payloads. +They do **not** prove that a current V1 1.18 server sends the rejected shapes. +Fixes should explicitly map supported historical forms at the protocol boundary, +not relax strict current contracts or add UI guesses. + +## Fixture/harness defects and unresolved cases + +| Fixture | Finding / next step | +| --- | --- | +| `apply-patch` | Synthetic message facts omit required `time.created`; session fact also omits required identity fields. Adapter rejects messages, so no patch can render. Repair synthetic fixture, then reassess formatter diff. | +| `diff` | File mention declares offsets `0..13`, but `@diff-test.txt` occurs at byte 39. Strict mention validation correctly rejects it. Correct source range before refreshing highlight expectation. | +| `mentions-with-ranges` | Both recorded end offsets exclude the last character of their declared `value`. Current contract uses exclusive UTF-16 ends; slices do not equal the mention values. Establish/migrate legacy offset convention, not a guessed UI highlight. | +| `part-before-message-delta` | Delta arrives before message and without any `message.part.updated` establishing that part. Adapter deliberately requires an identified text/reasoning part. Define whether this synthetic ordering should be supported through buffering/rehydration, or repair fixture. Do not fabricate native part facts. | +| `explore` | Child tool summaries disappear. Replay mocks always return empty child/message snapshots; batched delivery can precede child-observation subscription. Reproduce with proper child bootstrap snapshots before blaming runtime task renderer or regenerating summaries away. | +| `mcp-tool` | Final first-tool update explicitly replaces `input.thought` with `input = {}`. Old expected output retains earlier thought; current output shows final result. Second thought remains. Determine whether fixture intends a partial tool update or authoritative final state; generic Result sections are independently expected. | + +## Recommended order + +1. Keep the directory-routing regression test and reviewed header updates. +2. Repair invalid synthetic facts/ranges and model child bootstrap accurately. +3. Restore queued-message display and decide historical V1 compatibility scope. +4. Add narrow regressions for preserved selection, diagnostics, permission and + revert behavior before refreshing their cosmetic snapshot differences. +5. Regenerate only reviewed remaining display changes. Never make the suite green + by deleting missing-content expectations. 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 6f125216e..369b8377e 100644 --- a/tests/replay/formatter_snapshot_spec.lua +++ b/tests/replay/json_snapshot_spec.lua @@ -5,48 +5,53 @@ 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() 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 +60,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 000000000..0cde2881d --- /dev/null +++ b/tests/snapshot_assertions.lua @@ -0,0 +1,43 @@ +local assert = require('luassert') + +local M = {} + +---@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 + end + end + end + return copy +end + +---Compare valid fenced JSON structurally; 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/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/replay_batch_spec.lua b/tests/unit/replay_batch_spec.lua new file mode 100644 index 000000000..a7bec9033 --- /dev/null +++ b/tests/unit/replay_batch_spec.lua @@ -0,0 +1,86 @@ +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() + 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) +end) diff --git a/tests/replay/replay_startup_spec.lua b/tests/unit/replay_startup_spec.lua similarity index 100% rename from tests/replay/replay_startup_spec.lua rename to tests/unit/replay_startup_spec.lua diff --git a/tests/unit/snapshot_assertions_spec.lua b/tests/unit/snapshot_assertions_spec.lua new file mode 100644 index 000000000..7008a6637 --- /dev/null +++ b/tests/unit/snapshot_assertions_spec.lua @@ -0,0 +1,75 @@ +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('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 From 348793a84285b659be570652de237c499304ea93 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 2 Oct 2026 11:35:02 -0400 Subject: [PATCH 2/8] feat: capture and replay native protocol events Record protocol-tagged V1 and V2 streamed events, preserving native V2 timestamps and payloads. Expose capture saving through the debug helper and support protocol-aware replay connections. Add native V2 capture/replay coverage, document fixture capture, and classify the capture store as a topology foundation module. --- lua/opencode/event_capture.lua | 50 ++++++ lua/opencode/protocols/v1/observation.lua | 2 + .../protocols/v2/observation/events.lua | 8 + lua/opencode/ui/debug_helper.lua | 7 + scripts/dependency-topology/topology.jsonc | 1 + tests/helpers.lua | 164 +++++++++++++++--- tests/manual/README.md | 7 +- tests/manual/renderer_replay.lua | 49 +++++- tests/unit/v2_event_capture_spec.lua | 103 +++++++++++ 9 files changed, 363 insertions(+), 28 deletions(-) create mode 100644 lua/opencode/event_capture.lua create mode 100644 tests/unit/v2_event_capture_spec.lua diff --git a/lua/opencode/event_capture.lua b/lua/opencode/event_capture.lua new file mode 100644 index 000000000..dbc162497 --- /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/v1/observation.lua b/lua/opencode/protocols/v1/observation.lua index 905ab738e..bbd5dfaa8 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 = {} @@ -111,6 +112,7 @@ local function route_event(connection, event) 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 } }, diff --git a/lua/opencode/protocols/v2/observation/events.lua b/lua/opencode/protocols/v2/observation/events.lua index a616ba8e0..081f404b5 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 = {} @@ -423,6 +424,13 @@ function M.route(connection, event) 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/ui/debug_helper.lua b/lua/opencode/ui/debug_helper.lua index b0f8e316e..cc15cfaf9 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/scripts/dependency-topology/topology.jsonc b/scripts/dependency-topology/topology.jsonc index 9b133789f..5e554953f 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/helpers.lua b/tests/helpers.lua index d1fccd541..df0913bdf 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -90,6 +90,88 @@ 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_stream() + M._replay_started = false +end + +function M.clear_replay_stream() + M._replay_stream = nil + M._replay_started = false +end + function M.replay_setup() local config = require('opencode.config') local config_file = require('opencode.config_file') @@ -390,6 +472,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 +536,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 +573,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, @@ -494,17 +610,23 @@ local function dispatch_replay_event(event) ) return end - -- 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 (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 + -- 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 (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') + end end function M.replay_event(event) diff --git a/tests/manual/README.md b/tests/manual/README.md index 3fffd1143..82f7957f8 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/renderer_replay.lua b/tests/manual/renderer_replay.lua index 9dc3c9442..7ad446783 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,19 @@ 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 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) diff --git a/tests/unit/v2_event_capture_spec.lua b/tests/unit/v2_event_capture_spec.lua new file mode 100644 index 000000000..0356e0828 --- /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) From 559265354a3eeaf86c0870317095ad38e2d79701 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 2 Oct 2026 12:05:02 -0400 Subject: [PATCH 3/8] fix: restore V1 queued messages and legacy editor context Project queued user input from V1 execution state and invalidate its header when an assistant consumes it. Preserve queue facts across updates and snapshot refreshes. Decode validated historical synthetic context into neutral editor-context facts, including raw Neovim diagnostics. Keep metadata authoritative and ordinary synthetic text unchanged. Fix context border classification and hint rendering, add focused regression tests, and update only reviewed cosmetic snapshot expectations. --- lua/opencode/protocols/observation.lua | 4 +- lua/opencode/protocols/v1/normalize.lua | 44 +++- lua/opencode/protocols/v1/observation.lua | 18 ++ lua/opencode/ui/formatter.lua | 14 +- lua/opencode/ui/renderer/entries.lua | 1 + tests/data/diagnostics.expected.json | 4 +- .../permission-ask-new-approve.expected.json | 2 +- tests/data/permission-ask-new.expected.json | 2 +- tests/data/queue.expected.json | 2 +- tests/data/selection.expected.json | 32 +-- tests/replay/SNAPSHOT_TRIAGE.md | 24 ++- .../unit/protocol_v1_legacy_context_spec.lua | 188 ++++++++++++++++++ tests/unit/queued_message_spec.lua | 141 +++++++++++++ 13 files changed, 432 insertions(+), 44 deletions(-) create mode 100644 tests/unit/protocol_v1_legacy_context_spec.lua create mode 100644 tests/unit/queued_message_spec.lua diff --git a/lua/opencode/protocols/observation.lua b/lua/opencode/protocols/observation.lua index d48b2b82a..c99df70e4 100644 --- a/lua/opencode/protocols/observation.lua +++ b/lua/opencode/protocols/observation.lua @@ -21,6 +21,7 @@ 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 OpencodeObservationState ---@field session OpencodeSession @@ -75,7 +76,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 3fa47a8e7..552611ed7 100644 --- a/lua/opencode/protocols/v1/normalize.lua +++ b/lua/opencode/protocols/v1/normalize.lua @@ -181,11 +181,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 diff --git a/lua/opencode/protocols/v1/observation.lua b/lua/opencode/protocols/v1/observation.lua index bbd5dfaa8..916c826c4 100644 --- a/lua/opencode/protocols/v1/observation.lua +++ b/lua/opencode/protocols/v1/observation.lua @@ -237,11 +237,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, '; ') } } @@ -316,6 +325,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 diff --git a/lua/opencode/ui/formatter.lua b/lua/opencode/ui/formatter.lua index 3310f0890..fa83fa2fb 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/renderer/entries.lua b/lua/opencode/ui/renderer/entries.lua index 2b6fb2a23..15ed9f312 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/tests/data/diagnostics.expected.json b/tests/data/diagnostics.expected.json index 6c72d2181..1891d3f87 100644 --- a/tests/data/diagnostics.expected.json +++ b/tests/data/diagnostics.expected.json @@ -11297,7 +11297,7 @@ "", "Let's start with step 1: Add a nil check for `state.active_session` before accessing `.id`.", "", - "** edit** `/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/core.lua`", + "** edit** `lua/opencode/core.lua`", "", "`````lua", " ", @@ -11336,7 +11336,7 @@ "", "I'll fix this now.", "", - "** edit** `/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/core.lua` 1s", + "** edit** `lua/opencode/core.lua` 1s", "", "`````lua", " local params = {}", diff --git a/tests/data/permission-ask-new-approve.expected.json b/tests/data/permission-ask-new-approve.expected.json index dcd692cd7..0a028bc02 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.expected.json b/tests/data/permission-ask-new.expected.json index a0f57d755..b52cd58f4 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/queue.expected.json b/tests/data/queue.expected.json index 01e2fc598..8903e7ea2 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 d0a6c3361..df231b602 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/replay/SNAPSHOT_TRIAGE.md b/tests/replay/SNAPSHOT_TRIAGE.md index a11f99cff..6ce2fc18a 100644 --- a/tests/replay/SNAPSHOT_TRIAGE.md +++ b/tests/replay/SNAPSHOT_TRIAGE.md @@ -13,6 +13,18 @@ full-session viewport metadata. These failures are not all stale snapshots. - **12 reviewed header updates:** only agent/model header text changed. Buffer lines, actions, extmark positions, and non-header extmark properties matched. No missing-content expectations were removed. +- **V1 queued-message regression fixed:** new user input received during running + or retrying execution gets a protocol-owned queue fact. Message updates and + snapshot refreshes preserve it; a linked assistant reply clears it. Header + reconciliation now tracks that fact and renders `QUEUED`. The queue snapshot + retains its badge; only its unrelated missing-agent fallback changed to + `ASSISTANT`. +- **Legacy V1 context decoding fixed:** validated synthetic JSON declarations map + to the same editor-context facts as current metadata. Raw Neovim diagnostics + become neutral diagnostics with one-based positions. Metadata stays authoritative; + ordinary synthetic text is unchanged. Selection/cursor borders and hint severity + rendering are covered by regressions. Remaining cosmetic differences were + reviewed separately; no source captures or missing-context expectations changed. ## Regeneration-only changes @@ -37,12 +49,12 @@ full-session viewport metadata. These failures are not all stale snapshots. | Fixture | Evidence / next fix | | --- | --- | -| `queue` | Expected `QUEUED` header badge disappears. Its highlight still exists, but no formatter emits the badge. Restore queued-message projection/display; do not classify this as an agent-label change. | -| `selection` | Historical synthetic text carries `context_type` inside JSON, without `metadata.context_type`. V1 normalizer only recognizes the metadata form, so selection content disappears. Decide/support legacy persisted-context decoding. | -| `cursor_data` | Same legacy-context encoding; cursor excerpt disappears. | -| `diagnostics` | Same legacy-context encoding; diagnostic summary disappears. Historical diagnostic item fields also differ from current decoder contract. | -| `permission-ask-new` | Missing diagnostics from legacy context, plus model header change. Fix context interpretation before updating cosmetic differences. | -| `permission-ask-new-approve` | Same missing legacy diagnostics; do not regenerate them away. | +| `queue` | Fixed: restored V1 queue projection, header badge, and invalidation when an assistant consumes the user. Regression tests cover busy/retry/idle, metadata updates, snapshot refreshes, and linked replies. | +| `selection` | Fixed: both saved selections render again. Snapshot changed only to remove one duplicate border extmark; visible border rows, text, and actions match. | +| `cursor_data` | Fixed: saved cursor excerpt renders; existing snapshot passes unchanged. | +| `diagnostics` | Fixed: raw Neovim diagnostic records map to neutral facts; summary restored. Snapshot changed only for two relative tool paths. | +| `permission-ask-new` | Fixed: legacy diagnostic summary restored. Snapshot changed only for user model header text. | +| `permission-ask-new-approve` | Fixed: same restored diagnostics; only user model header expectation updated. | | `permission-prompt` | Capture uses historical `permission.updated` (`type`, `pattern`, `callID`), whereas current adapter accepts the newer permission contract. Missing prompt is a compatibility gap, not cosmetic drift. | | `shifting-and-multiple-perms` | Same unsupported historical permission events remove all prompts; added reference icon is independently cosmetic. | | `revert` | Historical `session.updated` lacks fields required by current `session_shape` (notably `slug`). Revert facts never reach rendering, so reverted messages remain visible. Resolve wire-version compatibility before changing expected output. | 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 000000000..f8a008f53 --- /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/queued_message_spec.lua b/tests/unit/queued_message_spec.lua new file mode 100644 index 000000000..67a136220 --- /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) From f5e522c406785492242add7692ab9ae69c6b666c Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 2 Oct 2026 14:11:50 -0400 Subject: [PATCH 4/8] fix: restore permission previews across protocols without duplication --- lua/opencode/protocols/observation.lua | 9 +- lua/opencode/protocols/v1/normalize.lua | 93 +++++++ lua/opencode/protocols/v1/observation.lua | 23 +- lua/opencode/protocols/v2/normalize.lua | 69 +++++ .../protocols/v2/observation/events.lua | 3 + .../protocols/v2/observation/resources.lua | 1 + lua/opencode/protocols/v2/types.lua | 1 + lua/opencode/ui/icons.lua | 2 + lua/opencode/ui/permission_window.lua | 50 +++- .../shifting-and-multiple-perms.expected.json | 117 +++++---- tests/unit/permission_window_spec.lua | 130 +++++++++ .../protocol_v1_observation_runtime_spec.lua | 125 +++++++++ .../protocol_v2_observation_runtime_spec.lua | 246 ++++++++++++++++++ 13 files changed, 799 insertions(+), 70 deletions(-) diff --git a/lua/opencode/protocols/observation.lua b/lua/opencode/protocols/observation.lua index c99df70e4..c6b3927eb 100644 --- a/lua/opencode/protocols/observation.lua +++ b/lua/opencode/protocols/observation.lua @@ -23,6 +23,13 @@ local resource_names = { ---@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 ---@field sync table @@ -31,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}} diff --git a/lua/opencode/protocols/v1/normalize.lua b/lua/opencode/protocols/v1/normalize.lua index 552611ed7..f84bf44d2 100644 --- a/lua/opencode/protocols/v1/normalize.lua +++ b/lua/opencode/protocols/v1/normalize.lua @@ -102,6 +102,13 @@ local session_shape = v.object({ } end) +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', sessionID = 'string', @@ -126,6 +133,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', @@ -654,9 +706,40 @@ 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) @@ -698,5 +781,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 916c826c4..073731f3c 100644 --- a/lua/opencode/protocols/v1/observation.lua +++ b/lua/opencode/protocols/v1/observation.lua @@ -110,6 +110,9 @@ 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 }) @@ -515,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) @@ -661,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 @@ -684,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 0e745f35f..4b6e124ca 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 081f404b5..74993ab70 100644 --- a/lua/opencode/protocols/v2/observation/events.lua +++ b/lua/opencode/protocols/v2/observation/events.lua @@ -420,6 +420,9 @@ 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 diff --git a/lua/opencode/protocols/v2/observation/resources.lua b/lua/opencode/protocols/v2/observation/resources.lua index 9746d604c..80b6c7d2a 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 3a50f142d..5098a8faa 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/icons.lua b/lua/opencode/ui/icons.lua index 6d64a821d..20595bbbe 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 a7b72ef3c..cc31a1176 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/tests/data/shifting-and-multiple-perms.expected.json b/tests/data/shifting-and-multiple-perms.expected.json index 2cdd45abf..a8670d470 100644 --- a/tests/data/shifting-and-multiple-perms.expected.json +++ b/tests/data/shifting-and-multiple-perms.expected.json @@ -315,6 +315,21 @@ ], [ 9, + 24, + 26, + { + "priority": 1000, + "ns_id": 3, + "hl_group": "OpencodeReference", + "right_gravity": true, + "end_col": 40, + "hl_eol": false, + "end_row": 24, + "end_right_gravity": false + } + ], + [ + 10, 83, 0, { @@ -352,7 +367,7 @@ } ], [ - 10, + 11, 83, 0, { @@ -371,7 +386,7 @@ } ], [ - 11, + 12, 84, 0, { @@ -391,7 +406,7 @@ } ], [ - 12, + 13, 85, 0, { @@ -411,7 +426,7 @@ } ], [ - 13, + 14, 88, 0, { @@ -449,7 +464,7 @@ } ], [ - 14, + 15, 88, 0, { @@ -468,7 +483,7 @@ } ], [ - 15, + 16, 111, 0, { @@ -506,7 +521,7 @@ } ], [ - 16, + 17, 111, 0, { @@ -525,7 +540,7 @@ } ], [ - 17, + 18, 112, 0, { @@ -545,7 +560,7 @@ } ], [ - 18, + 19, 113, 0, { @@ -565,7 +580,7 @@ } ], [ - 19, + 20, 116, 0, { @@ -603,7 +618,7 @@ } ], [ - 20, + 21, 116, 0, { @@ -622,7 +637,7 @@ } ], [ - 21, + 22, 125, 0, { @@ -660,7 +675,7 @@ } ], [ - 22, + 23, 127, 0, { @@ -671,7 +686,7 @@ } ], [ - 23, + 24, 127, 0, { @@ -691,7 +706,7 @@ } ], [ - 24, + 25, 128, 0, { @@ -711,7 +726,7 @@ } ], [ - 25, + 26, 129, 0, { @@ -731,7 +746,7 @@ } ], [ - 26, + 27, 130, 0, { @@ -751,7 +766,7 @@ } ], [ - 27, + 28, 131, 0, { @@ -771,7 +786,7 @@ } ], [ - 28, + 29, 132, 0, { @@ -791,7 +806,7 @@ } ], [ - 29, + 30, 133, 0, { @@ -821,7 +836,7 @@ } ], [ - 30, + 31, 133, 0, { @@ -841,7 +856,7 @@ } ], [ - 31, + 32, 134, 0, { @@ -871,7 +886,7 @@ } ], [ - 32, + 33, 134, 0, { @@ -891,7 +906,7 @@ } ], [ - 33, + 34, 135, 0, { @@ -921,7 +936,7 @@ } ], [ - 34, + 35, 135, 0, { @@ -941,7 +956,7 @@ } ], [ - 35, + 36, 136, 0, { @@ -971,7 +986,7 @@ } ], [ - 36, + 37, 136, 0, { @@ -991,7 +1006,7 @@ } ], [ - 37, + 38, 137, 0, { @@ -1023,7 +1038,7 @@ } ], [ - 38, + 39, 137, 0, { @@ -1043,7 +1058,7 @@ } ], [ - 39, + 40, 138, 0, { @@ -1073,7 +1088,7 @@ } ], [ - 40, + 41, 138, 0, { @@ -1093,7 +1108,7 @@ } ], [ - 41, + 42, 139, 0, { @@ -1123,7 +1138,7 @@ } ], [ - 42, + 43, 139, 0, { @@ -1143,7 +1158,7 @@ } ], [ - 43, + 44, 140, 0, { @@ -1173,7 +1188,7 @@ } ], [ - 44, + 45, 140, 0, { @@ -1193,7 +1208,7 @@ } ], [ - 45, + 46, 141, 0, { @@ -1223,7 +1238,7 @@ } ], [ - 46, + 47, 141, 0, { @@ -1243,7 +1258,7 @@ } ], [ - 47, + 48, 142, 0, { @@ -1263,7 +1278,7 @@ } ], [ - 48, + 49, 143, 0, { @@ -1283,7 +1298,7 @@ } ], [ - 49, + 50, 144, 0, { @@ -1303,7 +1318,7 @@ } ], [ - 50, + 51, 145, 0, { @@ -1314,7 +1329,7 @@ } ], [ - 51, + 52, 145, 0, { @@ -1334,7 +1349,7 @@ } ], [ - 52, + 53, 145, 2, { @@ -1353,7 +1368,7 @@ } ], [ - 53, + 54, 146, 0, { @@ -1373,7 +1388,7 @@ } ], [ - 54, + 55, 147, 0, { @@ -1393,7 +1408,7 @@ } ], [ - 55, + 56, 148, 0, { @@ -1413,7 +1428,7 @@ } ], [ - 56, + 57, 149, 0, { @@ -1433,7 +1448,7 @@ } ], [ - 57, + 58, 150, 0, { @@ -1453,7 +1468,7 @@ } ], [ - 58, + 59, 151, 0, { @@ -1498,7 +1513,7 @@ "- **Add** a blank line after writing new content (in `_write_formatted_data`)", "- **Remove** the trailing blank line before writing new content (also in `_write_formatted_data`)", "", - "### Changes needed in `renderer.lua`:", + "### Changes needed in  `renderer.lua`:", "", "1. **Add state tracking** (after line 14):", " ```lua", @@ -1606,7 +1621,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/unit/permission_window_spec.lua b/tests/unit/permission_window_spec.lua index 6a7cbe7ee..e9c7b35eb 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_observation_runtime_spec.lua b/tests/unit/protocol_v1_observation_runtime_spec.lua index 4067c3883..9d3ef954f 100644 --- a/tests/unit/protocol_v1_observation_runtime_spec.lua +++ b/tests/unit/protocol_v1_observation_runtime_spec.lua @@ -538,6 +538,131 @@ describe('V1 protocol Observation runtime', function() 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 38098409c..5c32d6cae 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() From c00c6df9408b9e7f81a8ec855f1db69aa569cf73 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 2 Oct 2026 15:04:02 -0400 Subject: [PATCH 5/8] fix(protocol): accept legacy V1 revert sessions without slug --- lua/opencode/protocols/v1/normalize.lua | 55 +++++++++++--- lua/opencode/protocols/v1/observation.lua | 4 +- tests/data/redo-once.expected.json | 6 +- tests/data/revert.expected.json | 2 +- tests/helpers.lua | 1 + .../protocol_v1_observation_runtime_spec.lua | 75 +++++++++++++++++++ 6 files changed, 125 insertions(+), 18 deletions(-) diff --git a/lua/opencode/protocols/v1/normalize.lua b/lua/opencode/protocols/v1/normalize.lua index f84bf44d2..6cc747468 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,40 @@ 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(), @@ -698,9 +723,15 @@ 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 diff --git a/lua/opencode/protocols/v1/observation.lua b/lua/opencode/protocols/v1/observation.lua index 073731f3c..c8f30b0ee 100644 --- a/lua/opencode/protocols/v1/observation.lua +++ b/lua/opencode/protocols/v1/observation.lua @@ -583,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( @@ -594,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', diff --git a/tests/data/redo-once.expected.json b/tests/data/redo-once.expected.json index 7fae05efc..24e7ae8f4 100644 --- a/tests/data/redo-once.expected.json +++ b/tests/data/redo-once.expected.json @@ -1339,7 +1339,7 @@ "", "I'll append a single word (\"again\") to the first line of `test.txt`. Applying a precise edit to the existing line now.", "", - "** edit** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", + "** edit** `test.txt`", "", "`````txt", " tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more", @@ -1366,14 +1366,14 @@ "", "I'll read `test.txt` to get the current first-line content, then append one word (\"again2\") to that line. Proceeding to read the file.", "", - "** read** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", + "** read** `test.txt`", "", "----", "", "", "Now I'll append the word \"again2\" to the first line. I'll apply an exact in-place edit to update that line.", "", - "** edit** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", + "** edit** `test.txt`", "", "`````txt", " tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more again", diff --git a/tests/data/revert.expected.json b/tests/data/revert.expected.json index 8d5780e29..dff994065 100644 --- a/tests/data/revert.expected.json +++ b/tests/data/revert.expected.json @@ -883,7 +883,7 @@ "", "Proceeding to update the file now.", "", - "** write** `/home/francis/Projects/_nvim/opencode.nvim/poem.md`", + "** write** `poem.md`", "", "`````markdown", "Lantern", diff --git a/tests/helpers.lua b/tests/helpers.lua index df0913bdf..417384d7a 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -622,6 +622,7 @@ local function dispatch_replay_event(event) properties.sessionID = properties.sessionID or (type(properties.info) == 'table' and properties.info.sessionID) 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 }, diff --git a/tests/unit/protocol_v1_observation_runtime_spec.lua b/tests/unit/protocol_v1_observation_runtime_spec.lua index 9d3ef954f..4effba46d 100644 --- a/tests/unit/protocol_v1_observation_runtime_spec.lua +++ b/tests/unit/protocol_v1_observation_runtime_spec.lua @@ -538,6 +538,81 @@ 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() From 36c145588dbdd809262a3b79bb776bbfaee60634 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 6 Oct 2026 08:29:46 -0400 Subject: [PATCH 6/8] fix(replay): repair fixture and child bootstrap for V1 events --- tests/data/apply-patch.expected.json | 58 ++++- tests/data/apply-patch.json | 6 +- tests/data/diff.json | 4 +- tests/data/explore.expected.json | 83 ++------ tests/data/mcp-tool.expected.json | 198 ++++++++++++++---- tests/data/mcp-tool.json | 9 +- tests/data/mentions-with-ranges.json | 4 +- ...tiple-question-ask-reply-all.expected.json | 17 +- .../part-before-message-delta.expected.json | 2 +- tests/data/part-before-message-delta.json | 12 ++ tests/helpers.lua | 100 +++++++-- tests/manual/renderer_replay.lua | 2 +- tests/replay/SNAPSHOT_TRIAGE.md | 87 -------- tests/unit/replay_batch_spec.lua | 82 ++++++++ 14 files changed, 433 insertions(+), 231 deletions(-) delete mode 100644 tests/replay/SNAPSHOT_TRIAGE.md diff --git a/tests/data/apply-patch.expected.json b/tests/data/apply-patch.expected.json index f479ce69d..8bcc32ce4 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 28c3783a2..9249faba3 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 04ddf8a3c..c3a47811c 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 eef239981..c6899a0a8 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,22 +1620,22 @@ "", "** task** `Find task tool rendering (@explore)` 60s", "", - " ** read** `/home/francis/Projects/_nvim/opencode.nvim/`", + " ** read** `~/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`", - " ** read** `/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/formatter.lua`", - " ** read** `/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/types.lua`", - " ** read** `/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/icons.lua`", - " ** read** `/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/formatter.lua`", + " ** read** `lua/opencode/ui/output.lua`", + " ** read** `lua/opencode/ui/formatter.lua`", + " ** read** `lua/opencode/types.lua`", + " ** read** `lua/opencode/ui/icons.lua`", + " ** read** `lua/opencode/ui/formatter.lua`", " ** grep** `/home/francis/Projects/_nvim/opencode.nvim/lua format_tool_part\\|_format_task_tool\\|format_action`", " ** grep** `/home/francis/Projects/_nvim/opencode.nvim/lua _format_tool\\b`", " ** grep** `/home/francis/Projects/_nvim/opencode.nvim/tests task`", - " ** read** `/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/formatter.lua`", + " ** read** `lua/opencode/ui/formatter.lua`", " ** grep** `/home/francis/Projects/_nvim/opencode.nvim/tests _format_task\\|task_tool\\|TaskTool`", " ** grep** `/home/francis/Projects/_nvim/opencode.nvim/tests task`", - " ** read** `/home/francis/Projects/_nvim/opencode.nvim/tests/unit/formatter_spec.lua`", - " ** read** `/home/francis/Projects/_nvim/opencode.nvim/tests/replay/renderer_spec.lua`", + " ** read** `tests/unit/formatter_spec.lua`", + " ** read** `tests/replay/renderer_spec.lua`", " ** glob** `**/*.json`", " ** grep** `/home/francis/Projects/_nvim/opencode.nvim/tests/data task`", "", @@ -1748,7 +1691,7 @@ "----", "", "", - "The task tool is rendered primarily in `lua/opencode/ui/formatter.lua`:", + "The task tool is rendered primarily in  `lua/opencode/ui/formatter.lua`:", "", "- **Lines 665–735** — `M._format_tool()`: the central dispatch function that routes tool names to their specific formatters. The `'task'` branch is at **lines 700–707**.", "- **Lines 737–801** — `M._format_task_tool()`: the full task tool renderer, which:", @@ -1757,8 +1700,8 @@ " - Attaches a `select_child_session` contextual action (lines 793–800)", "", "Supporting files:", - "- `lua/opencode/types.lua:228–308` — type definitions for `TaskToolInput`, `TaskToolMetadata`, and `TaskToolSummaryItem`", - "- `lua/opencode/ui/icons.lua:13,55` — icon definitions for the task tool", + "- ` lua/opencode/types.lua:228–308` — type definitions for `TaskToolInput`, `TaskToolMetadata`, and `TaskToolSummaryItem`", + "- ` lua/opencode/ui/icons.lua:13,55` — icon definitions for the task tool", "", "" ], diff --git a/tests/data/mcp-tool.expected.json b/tests/data/mcp-tool.expected.json index ad48b20b9..8b1dd0e19 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 ac8cb767d..4f08c8ff8 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 920d67732..3b9ffed44 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/multiple-question-ask-reply-all.expected.json b/tests/data/multiple-question-ask-reply-all.expected.json index aa16e89d7..c96a7d66a 100644 --- a/tests/data/multiple-question-ask-reply-all.expected.json +++ b/tests/data/multiple-question-ask-reply-all.expected.json @@ -590,6 +590,21 @@ "virt_text_pos": "right_align", "virt_text_repeat_linebreak": false } + ], + [ + 24, + 31, + 37, + { + "end_col": 49, + "end_right_gravity": false, + "end_row": 31, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } ] ], "lines": [ @@ -624,7 +639,7 @@ "Thanks for your answers! You've indicated:", "", "- Preferred testing framework: **busted**", - "- Typical test running method: **run_tests.sh (Recommended)**", + "- Typical test running method: ** run_tests.sh (Recommended)**", "", "I have this in mind for any future testing, troubleshooting, or automation tasks you request for this Neovim Lua codebase. If you have any follow-up questions or tasks (such as test writing, modification, running, debugging, or reporting), let me know—I'll tailor my workflow to these preferences for maximal compatibility and consistency!", "", diff --git a/tests/data/part-before-message-delta.expected.json b/tests/data/part-before-message-delta.expected.json index 19b5a44b0..c6c8015d4 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 19522f9f7..6eedd90a4 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/helpers.lua b/tests/helpers.lua index 417384d7a..2ac383b24 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -5,6 +5,11 @@ local M = {} M.MOCK_CWD = '/mock/project/path' +-- 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 +45,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({}) @@ -163,8 +186,15 @@ function M.event_protocol(events) 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() @@ -198,7 +228,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() @@ -393,17 +423,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 @@ -583,6 +602,51 @@ function M.wait_for_replay_ready() end end +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 @@ -615,12 +679,14 @@ local function dispatch_replay_event(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({ diff --git a/tests/manual/renderer_replay.lua b/tests/manual/renderer_replay.lua index 7ad446783..27ad5a961 100644 --- a/tests/manual/renderer_replay.lua +++ b/tests/manual/renderer_replay.lua @@ -237,7 +237,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/SNAPSHOT_TRIAGE.md b/tests/replay/SNAPSHOT_TRIAGE.md deleted file mode 100644 index 6ce2fc18a..000000000 --- a/tests/replay/SNAPSHOT_TRIAGE.md +++ /dev/null @@ -1,87 +0,0 @@ -# Restored V1 snapshot triage - -The restored suite initially had 30 failures after batching and ignoring legacy -full-session viewport metadata. These failures are not all stale snapshots. - -## Fixes made during triage - -- **Replay harness bug:** V1 envelopes used the UI's active-session directory. - After a valid `session.updated`, observation facts had the new directory while - UI state still had the old directory. Later events in the batch were dropped. - Envelopes now use the observation's current session location. A regression test - covers `question-multiple-choices`. -- **12 reviewed header updates:** only agent/model header text changed. Buffer - lines, actions, extmark positions, and non-header extmark properties matched. - No missing-content expectations were removed. -- **V1 queued-message regression fixed:** new user input received during running - or retrying execution gets a protocol-owned queue fact. Message updates and - snapshot refreshes preserve it; a linked assistant reply clears it. Header - reconciliation now tracks that fact and renders `QUEUED`. The queue snapshot - retains its badge; only its unrelated missing-agent fallback changed to - `ASSISTANT`. -- **Legacy V1 context decoding fixed:** validated synthetic JSON declarations map - to the same editor-context facts as current metadata. Raw Neovim diagnostics - become neutral diagnostics with one-based positions. Metadata stays authoritative; - ordinary synthetic text is unchanged. Selection/cursor borders and hint severity - rendering are covered by regressions. Remaining cosmetic differences were - reviewed separately; no source captures or missing-context expectations changed. - -## Regeneration-only changes - -| Fixture | Evidence | Status | -| --- | --- | --- | -| `message-removal` | Assistant facts have no agent/mode; current header correctly says `ASSISTANT`, not invented `BUILD`. | Updated | -| `multiple-messages-synthetic` | Same missing-agent fallback; no content/action changes. | Updated | -| `provider-overloaded-status` | User model is supplied by the fixture and now appears in its header. | Updated | -| `permission-ask-new-deny` | Model header only; denial output/actions unchanged. | Updated | -| `question-ask` | Model header only; question output/actions unchanged. | Updated | -| `question-ask-other` | Model header only; question output/actions unchanged. | Updated | -| `question-ask-replied` | Model header only; answer output/actions unchanged. | Updated | -| `multiple-question-ask` | Model header only; stacked questions/actions unchanged. | Updated | -| `multiple-question-ask-reply-all` | Model header only; answered questions/actions unchanged. | Updated | -| `question-multiple-choices` | After fixing replay directory routing, only model header differs. | Updated | -| `question-multiple-choices-answered` | After fixing replay directory routing, only model header differs. | Updated | -| `question-multiple-other` | After fixing replay directory routing, only model header differs. | Updated | -| `redo-all` | Buffer diff contains only absolute-to-relative tool paths; actions match. | Updated | -| `tool-invalid` | Generic tool formatter adds Input/Result sections; existing error remains. | Regenerated | - -## Bugs or compatibility regressions: do not regenerate missing content - -| Fixture | Evidence / next fix | -| --- | --- | -| `queue` | Fixed: restored V1 queue projection, header badge, and invalidation when an assistant consumes the user. Regression tests cover busy/retry/idle, metadata updates, snapshot refreshes, and linked replies. | -| `selection` | Fixed: both saved selections render again. Snapshot changed only to remove one duplicate border extmark; visible border rows, text, and actions match. | -| `cursor_data` | Fixed: saved cursor excerpt renders; existing snapshot passes unchanged. | -| `diagnostics` | Fixed: raw Neovim diagnostic records map to neutral facts; summary restored. Snapshot changed only for two relative tool paths. | -| `permission-ask-new` | Fixed: legacy diagnostic summary restored. Snapshot changed only for user model header text. | -| `permission-ask-new-approve` | Fixed: same restored diagnostics; only user model header expectation updated. | -| `permission-prompt` | Capture uses historical `permission.updated` (`type`, `pattern`, `callID`), whereas current adapter accepts the newer permission contract. Missing prompt is a compatibility gap, not cosmetic drift. | -| `shifting-and-multiple-perms` | Same unsupported historical permission events remove all prompts; added reference icon is independently cosmetic. | -| `revert` | Historical `session.updated` lacks fields required by current `session_shape` (notably `slug`). Revert facts never reach rendering, so reverted messages remain visible. Resolve wire-version compatibility before changing expected output. | -| `redo-once` | Same rejected historical session updates; revert summary disappears and reverted conversation remains visible. Relative tool paths are independently cosmetic. | - -Compatibility regressions above reproduce with the historical wire payloads. -They do **not** prove that a current V1 1.18 server sends the rejected shapes. -Fixes should explicitly map supported historical forms at the protocol boundary, -not relax strict current contracts or add UI guesses. - -## Fixture/harness defects and unresolved cases - -| Fixture | Finding / next step | -| --- | --- | -| `apply-patch` | Synthetic message facts omit required `time.created`; session fact also omits required identity fields. Adapter rejects messages, so no patch can render. Repair synthetic fixture, then reassess formatter diff. | -| `diff` | File mention declares offsets `0..13`, but `@diff-test.txt` occurs at byte 39. Strict mention validation correctly rejects it. Correct source range before refreshing highlight expectation. | -| `mentions-with-ranges` | Both recorded end offsets exclude the last character of their declared `value`. Current contract uses exclusive UTF-16 ends; slices do not equal the mention values. Establish/migrate legacy offset convention, not a guessed UI highlight. | -| `part-before-message-delta` | Delta arrives before message and without any `message.part.updated` establishing that part. Adapter deliberately requires an identified text/reasoning part. Define whether this synthetic ordering should be supported through buffering/rehydration, or repair fixture. Do not fabricate native part facts. | -| `explore` | Child tool summaries disappear. Replay mocks always return empty child/message snapshots; batched delivery can precede child-observation subscription. Reproduce with proper child bootstrap snapshots before blaming runtime task renderer or regenerating summaries away. | -| `mcp-tool` | Final first-tool update explicitly replaces `input.thought` with `input = {}`. Old expected output retains earlier thought; current output shows final result. Second thought remains. Determine whether fixture intends a partial tool update or authoritative final state; generic Result sections are independently expected. | - -## Recommended order - -1. Keep the directory-routing regression test and reviewed header updates. -2. Repair invalid synthetic facts/ranges and model child bootstrap accurately. -3. Restore queued-message display and decide historical V1 compatibility scope. -4. Add narrow regressions for preserved selection, diagnostics, permission and - revert behavior before refreshing their cosmetic snapshot differences. -5. Regenerate only reviewed remaining display changes. Never make the suite green - by deleting missing-content expectations. diff --git a/tests/unit/replay_batch_spec.lua b/tests/unit/replay_batch_spec.lua index a7bec9033..701c92532 100644 --- a/tests/unit/replay_batch_spec.lua +++ b/tests/unit/replay_batch_spec.lua @@ -83,4 +83,86 @@ describe('replay event batching', function() :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) From 38336621bd18975aa8770508b686d4814162fcc9 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 6 Oct 2026 09:44:40 -0400 Subject: [PATCH 7/8] fix(tests): isolate replay environment from host filesystem --- tests/data/diagnostics.expected.json | 4 +- tests/data/explore.expected.json | 24 ++-- ...tiple-question-ask-reply-all.expected.json | 17 +-- tests/data/redo-all.expected.json | 10 +- tests/data/redo-once.expected.json | 6 +- tests/data/revert.expected.json | 2 +- .../shifting-and-multiple-perms.expected.json | 115 ++++++++---------- tests/helpers.lua | 37 ++++++ tests/manual/regenerate_expected.lua | 2 + tests/manual/renderer_replay.lua | 2 + tests/replay/README.md | 8 ++ tests/replay/json_snapshot_spec.lua | 1 + tests/snapshot_assertions.lua | 18 ++- tests/unit/replay_batch_spec.lua | 1 + tests/unit/replay_environment_spec.lua | 51 ++++++++ tests/unit/replay_startup_spec.lua | 6 +- tests/unit/snapshot_assertions_spec.lua | 20 +++ 17 files changed, 217 insertions(+), 107 deletions(-) create mode 100644 tests/unit/replay_environment_spec.lua diff --git a/tests/data/diagnostics.expected.json b/tests/data/diagnostics.expected.json index 1891d3f87..6c72d2181 100644 --- a/tests/data/diagnostics.expected.json +++ b/tests/data/diagnostics.expected.json @@ -11297,7 +11297,7 @@ "", "Let's start with step 1: Add a nil check for `state.active_session` before accessing `.id`.", "", - "** edit** `lua/opencode/core.lua`", + "** edit** `/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/core.lua`", "", "`````lua", " ", @@ -11336,7 +11336,7 @@ "", "I'll fix this now.", "", - "** edit** `lua/opencode/core.lua` 1s", + "** edit** `/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/core.lua` 1s", "", "`````lua", " local params = {}", diff --git a/tests/data/explore.expected.json b/tests/data/explore.expected.json index c6899a0a8..a28300ba9 100644 --- a/tests/data/explore.expected.json +++ b/tests/data/explore.expected.json @@ -1620,22 +1620,22 @@ "", "** task** `Find task tool rendering (@explore)` 60s", "", - " ** read** `~/Projects/_nvim/opencode.nvim`", + " ** read** `/home/francis/Projects/_nvim/opencode.nvim`", " ** glob** `**/*.lua`", " ** grep** `/home/francis/Projects/_nvim/opencode.nvim/lua task`", - " ** read** `lua/opencode/ui/output.lua`", - " ** read** `lua/opencode/ui/formatter.lua`", - " ** read** `lua/opencode/types.lua`", - " ** read** `lua/opencode/ui/icons.lua`", - " ** read** `lua/opencode/ui/formatter.lua`", + " ** read** `/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/output.lua`", + " ** read** `/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/formatter.lua`", + " ** read** `/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/types.lua`", + " ** read** `/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/icons.lua`", + " ** read** `/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/formatter.lua`", " ** grep** `/home/francis/Projects/_nvim/opencode.nvim/lua format_tool_part\\|_format_task_tool\\|format_action`", " ** grep** `/home/francis/Projects/_nvim/opencode.nvim/lua _format_tool\\b`", " ** grep** `/home/francis/Projects/_nvim/opencode.nvim/tests task`", - " ** read** `lua/opencode/ui/formatter.lua`", + " ** read** `/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/formatter.lua`", " ** grep** `/home/francis/Projects/_nvim/opencode.nvim/tests _format_task\\|task_tool\\|TaskTool`", " ** grep** `/home/francis/Projects/_nvim/opencode.nvim/tests task`", - " ** read** `tests/unit/formatter_spec.lua`", - " ** read** `tests/replay/renderer_spec.lua`", + " ** read** `/home/francis/Projects/_nvim/opencode.nvim/tests/unit/formatter_spec.lua`", + " ** read** `/home/francis/Projects/_nvim/opencode.nvim/tests/replay/renderer_spec.lua`", " ** glob** `**/*.json`", " ** grep** `/home/francis/Projects/_nvim/opencode.nvim/tests/data task`", "", @@ -1691,7 +1691,7 @@ "----", "", "", - "The task tool is rendered primarily in  `lua/opencode/ui/formatter.lua`:", + "The task tool is rendered primarily in `lua/opencode/ui/formatter.lua`:", "", "- **Lines 665–735** — `M._format_tool()`: the central dispatch function that routes tool names to their specific formatters. The `'task'` branch is at **lines 700–707**.", "- **Lines 737–801** — `M._format_task_tool()`: the full task tool renderer, which:", @@ -1700,8 +1700,8 @@ " - Attaches a `select_child_session` contextual action (lines 793–800)", "", "Supporting files:", - "- ` lua/opencode/types.lua:228–308` — type definitions for `TaskToolInput`, `TaskToolMetadata`, and `TaskToolSummaryItem`", - "- ` lua/opencode/ui/icons.lua:13,55` — icon definitions for the task tool", + "- `lua/opencode/types.lua:228–308` — type definitions for `TaskToolInput`, `TaskToolMetadata`, and `TaskToolSummaryItem`", + "- `lua/opencode/ui/icons.lua:13,55` — icon definitions for the task tool", "", "" ], diff --git a/tests/data/multiple-question-ask-reply-all.expected.json b/tests/data/multiple-question-ask-reply-all.expected.json index c96a7d66a..aa16e89d7 100644 --- a/tests/data/multiple-question-ask-reply-all.expected.json +++ b/tests/data/multiple-question-ask-reply-all.expected.json @@ -590,21 +590,6 @@ "virt_text_pos": "right_align", "virt_text_repeat_linebreak": false } - ], - [ - 24, - 31, - 37, - { - "end_col": 49, - "end_right_gravity": false, - "end_row": 31, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } ] ], "lines": [ @@ -639,7 +624,7 @@ "Thanks for your answers! You've indicated:", "", "- Preferred testing framework: **busted**", - "- Typical test running method: ** run_tests.sh (Recommended)**", + "- Typical test running method: **run_tests.sh (Recommended)**", "", "I have this in mind for any future testing, troubleshooting, or automation tasks you request for this Neovim Lua codebase. If you have any follow-up questions or tasks (such as test writing, modification, running, debugging, or reporting), let me know—I'll tailor my workflow to these preferences for maximal compatibility and consistency!", "", diff --git a/tests/data/redo-all.expected.json b/tests/data/redo-all.expected.json index 189892415..285d83386 100644 --- a/tests/data/redo-all.expected.json +++ b/tests/data/redo-all.expected.json @@ -1949,7 +1949,7 @@ "", "I'll append a single word (\"again\") to the first line of `test.txt`. Applying a precise edit to the existing line now.", "", - "** edit** `test.txt`", + "** edit** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", "", "`````txt", " tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more", @@ -1976,14 +1976,14 @@ "", "I'll read `test.txt` to get the current first-line content, then append one word (\"again2\") to that line. Proceeding to read the file.", "", - "** read** `test.txt`", + "** read** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", "", "----", "", "", "Now I'll append the word \"again2\" to the first line. I'll apply an exact in-place edit to update that line.", "", - "** edit** `test.txt`", + "** edit** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", "", "`````txt", " tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more again", @@ -2010,14 +2010,14 @@ "", "I'll read `test.txt` to get the current first-line content, then append the word `again3`. Proceeding to read the file.", "", - "** read** `test.txt`", + "** read** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", "", "----", "", "", "I'll append the word \"again3\" to the first line of `test.txt` with an exact in-place edit. Applying the change now.", "", - "** edit** `test.txt`", + "** edit** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", "", "`````txt", " tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more again again2", diff --git a/tests/data/redo-once.expected.json b/tests/data/redo-once.expected.json index 24e7ae8f4..7fae05efc 100644 --- a/tests/data/redo-once.expected.json +++ b/tests/data/redo-once.expected.json @@ -1339,7 +1339,7 @@ "", "I'll append a single word (\"again\") to the first line of `test.txt`. Applying a precise edit to the existing line now.", "", - "** edit** `test.txt`", + "** edit** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", "", "`````txt", " tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more", @@ -1366,14 +1366,14 @@ "", "I'll read `test.txt` to get the current first-line content, then append one word (\"again2\") to that line. Proceeding to read the file.", "", - "** read** `test.txt`", + "** read** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", "", "----", "", "", "Now I'll append the word \"again2\" to the first line. I'll apply an exact in-place edit to update that line.", "", - "** edit** `test.txt`", + "** edit** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", "", "`````txt", " tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more again", diff --git a/tests/data/revert.expected.json b/tests/data/revert.expected.json index dff994065..8d5780e29 100644 --- a/tests/data/revert.expected.json +++ b/tests/data/revert.expected.json @@ -883,7 +883,7 @@ "", "Proceeding to update the file now.", "", - "** write** `poem.md`", + "** write** `/home/francis/Projects/_nvim/opencode.nvim/poem.md`", "", "`````markdown", "Lantern", diff --git a/tests/data/shifting-and-multiple-perms.expected.json b/tests/data/shifting-and-multiple-perms.expected.json index a8670d470..7155c2b08 100644 --- a/tests/data/shifting-and-multiple-perms.expected.json +++ b/tests/data/shifting-and-multiple-perms.expected.json @@ -315,21 +315,6 @@ ], [ 9, - 24, - 26, - { - "priority": 1000, - "ns_id": 3, - "hl_group": "OpencodeReference", - "right_gravity": true, - "end_col": 40, - "hl_eol": false, - "end_row": 24, - "end_right_gravity": false - } - ], - [ - 10, 83, 0, { @@ -367,7 +352,7 @@ } ], [ - 11, + 10, 83, 0, { @@ -386,7 +371,7 @@ } ], [ - 12, + 11, 84, 0, { @@ -406,7 +391,7 @@ } ], [ - 13, + 12, 85, 0, { @@ -426,7 +411,7 @@ } ], [ - 14, + 13, 88, 0, { @@ -464,7 +449,7 @@ } ], [ - 15, + 14, 88, 0, { @@ -483,7 +468,7 @@ } ], [ - 16, + 15, 111, 0, { @@ -521,7 +506,7 @@ } ], [ - 17, + 16, 111, 0, { @@ -540,7 +525,7 @@ } ], [ - 18, + 17, 112, 0, { @@ -560,7 +545,7 @@ } ], [ - 19, + 18, 113, 0, { @@ -580,7 +565,7 @@ } ], [ - 20, + 19, 116, 0, { @@ -618,7 +603,7 @@ } ], [ - 21, + 20, 116, 0, { @@ -637,7 +622,7 @@ } ], [ - 22, + 21, 125, 0, { @@ -675,7 +660,7 @@ } ], [ - 23, + 22, 127, 0, { @@ -686,7 +671,7 @@ } ], [ - 24, + 23, 127, 0, { @@ -706,7 +691,7 @@ } ], [ - 25, + 24, 128, 0, { @@ -726,7 +711,7 @@ } ], [ - 26, + 25, 129, 0, { @@ -746,7 +731,7 @@ } ], [ - 27, + 26, 130, 0, { @@ -766,7 +751,7 @@ } ], [ - 28, + 27, 131, 0, { @@ -786,7 +771,7 @@ } ], [ - 29, + 28, 132, 0, { @@ -806,7 +791,7 @@ } ], [ - 30, + 29, 133, 0, { @@ -836,7 +821,7 @@ } ], [ - 31, + 30, 133, 0, { @@ -856,7 +841,7 @@ } ], [ - 32, + 31, 134, 0, { @@ -886,7 +871,7 @@ } ], [ - 33, + 32, 134, 0, { @@ -906,7 +891,7 @@ } ], [ - 34, + 33, 135, 0, { @@ -936,7 +921,7 @@ } ], [ - 35, + 34, 135, 0, { @@ -956,7 +941,7 @@ } ], [ - 36, + 35, 136, 0, { @@ -986,7 +971,7 @@ } ], [ - 37, + 36, 136, 0, { @@ -1006,7 +991,7 @@ } ], [ - 38, + 37, 137, 0, { @@ -1038,7 +1023,7 @@ } ], [ - 39, + 38, 137, 0, { @@ -1058,7 +1043,7 @@ } ], [ - 40, + 39, 138, 0, { @@ -1088,7 +1073,7 @@ } ], [ - 41, + 40, 138, 0, { @@ -1108,7 +1093,7 @@ } ], [ - 42, + 41, 139, 0, { @@ -1138,7 +1123,7 @@ } ], [ - 43, + 42, 139, 0, { @@ -1158,7 +1143,7 @@ } ], [ - 44, + 43, 140, 0, { @@ -1188,7 +1173,7 @@ } ], [ - 45, + 44, 140, 0, { @@ -1208,7 +1193,7 @@ } ], [ - 46, + 45, 141, 0, { @@ -1238,7 +1223,7 @@ } ], [ - 47, + 46, 141, 0, { @@ -1258,7 +1243,7 @@ } ], [ - 48, + 47, 142, 0, { @@ -1278,7 +1263,7 @@ } ], [ - 49, + 48, 143, 0, { @@ -1298,7 +1283,7 @@ } ], [ - 50, + 49, 144, 0, { @@ -1318,7 +1303,7 @@ } ], [ - 51, + 50, 145, 0, { @@ -1329,7 +1314,7 @@ } ], [ - 52, + 51, 145, 0, { @@ -1349,7 +1334,7 @@ } ], [ - 53, + 52, 145, 2, { @@ -1368,7 +1353,7 @@ } ], [ - 54, + 53, 146, 0, { @@ -1388,7 +1373,7 @@ } ], [ - 55, + 54, 147, 0, { @@ -1408,7 +1393,7 @@ } ], [ - 56, + 55, 148, 0, { @@ -1428,7 +1413,7 @@ } ], [ - 57, + 56, 149, 0, { @@ -1448,7 +1433,7 @@ } ], [ - 58, + 57, 150, 0, { @@ -1468,7 +1453,7 @@ } ], [ - 59, + 58, 151, 0, { @@ -1513,7 +1498,7 @@ "- **Add** a blank line after writing new content (in `_write_formatted_data`)", "- **Remove** the trailing blank line before writing new content (also in `_write_formatted_data`)", "", - "### Changes needed in  `renderer.lua`:", + "### Changes needed in `renderer.lua`:", "", "1. **Add state tracking** (after line 14):", " ```lua", diff --git a/tests/helpers.lua b/tests/helpers.lua index 2ac383b24..4124f4430 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -5,6 +5,42 @@ 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 = {} @@ -203,6 +239,7 @@ function M.clear_replay_stream() 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') diff --git a/tests/manual/regenerate_expected.lua b/tests/manual/regenerate_expected.lua index e695afd34..ec891775d 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 27ad5a961..962cc4fb9 100644 --- a/tests/manual/renderer_replay.lua +++ b/tests/manual/renderer_replay.lua @@ -99,6 +99,7 @@ function M.load_events(file_path) return false end + helpers.isolate_replay_environment() M.events = events M.reset() state.session.clear_active() @@ -150,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() diff --git a/tests/replay/README.md b/tests/replay/README.md index c66d3f31b..91a233f16 100644 --- a/tests/replay/README.md +++ b/tests/replay/README.md @@ -6,11 +6,19 @@ 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, diff --git a/tests/replay/json_snapshot_spec.lua b/tests/replay/json_snapshot_spec.lua index 369b8377e..b5cece19c 100644 --- a/tests/replay/json_snapshot_spec.lua +++ b/tests/replay/json_snapshot_spec.lua @@ -24,6 +24,7 @@ describe('JSON replay snapshots', function() end) after_each(function() + helpers.restore_replay_environment() config.debug.show_ids = original_show_ids vim.notify = original_notify ui.close_windows(state.windows) diff --git a/tests/snapshot_assertions.lua b/tests/snapshot_assertions.lua index 0cde2881d..ac7485a17 100644 --- a/tests/snapshot_assertions.lua +++ b/tests/snapshot_assertions.lua @@ -2,6 +2,19 @@ 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) @@ -27,13 +40,16 @@ local function normalized(snapshot) 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 valid fenced JSON structurally; all other snapshot fields remain exact. +---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) diff --git a/tests/unit/replay_batch_spec.lua b/tests/unit/replay_batch_spec.lua index 701c92532..e17b25a3c 100644 --- a/tests/unit/replay_batch_spec.lua +++ b/tests/unit/replay_batch_spec.lua @@ -21,6 +21,7 @@ describe('replay event batching', function() end) after_each(function() + helpers.restore_replay_environment() if wait_stub then wait_stub:revert() wait_stub = nil diff --git a/tests/unit/replay_environment_spec.lua b/tests/unit/replay_environment_spec.lua new file mode 100644 index 000000000..941a30f4a --- /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/unit/replay_startup_spec.lua b/tests/unit/replay_startup_spec.lua index 8ed362448..330b8cf83 100644 --- a/tests/unit/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 index 7008a6637..3ede9c691 100644 --- a/tests/unit/snapshot_assertions_spec.lua +++ b/tests/unit/snapshot_assertions_spec.lua @@ -40,6 +40,26 @@ describe('snapshot JSON comparison', function() 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() From 08eb70c5a6ac430f9559dfabd11ace82364a87cc Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 6 Oct 2026 10:00:47 -0400 Subject: [PATCH 8/8] test(renderer): make reconciliation tests deterministic --- tests/unit/renderer_reconciliation_spec.lua | 27 +++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/tests/unit/renderer_reconciliation_spec.lua b/tests/unit/renderer_reconciliation_spec.lua index ae0bc2554..5fb9d9ffe 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)