From f7bd643780e5ae8e647383d49420fb2b244cdf49 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 14 Oct 2026 08:45:34 -0400 Subject: [PATCH 1/3] ci: enforce architecture checks in pull requests --- .github/workflows/tests.yml | 46 ++++++++++ AGENTS.md | 3 +- Makefile | 15 +++- scripts/dependency-topology/README.md | 34 +++++++ scripts/dependency-topology/scan_analysis.py | 9 +- scripts/dependency-topology/scan_topology.py | 93 +++++++++++++++++++- scripts/dependency-topology/topology.jsonc | 14 ++- 7 files changed, 204 insertions(+), 10 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 988675dd7..835e82246 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -49,6 +49,52 @@ jobs: - name: Run type checks run: make typecheck ARGS="-f github" + architecture: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + name: architecture check + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Check module imports against layer rules + id: check + env: + BASE_REF: ${{ github.base_ref }} + run: | + set +e + make -s topology-diff ARGS="--from origin/$BASE_REF --to HEAD --check --markdown" > architecture-report.md + echo "status=$?" >> "$GITHUB_OUTPUT" + cat architecture-report.md >> "$GITHUB_STEP_SUMMARY" + + # Fork PRs get a read-only token; the job summary still carries the report. + - name: Post report on the pull request + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + run: | + [ -s architecture-report.md ] || exit 0 + marker='' + id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" --paginate \ + --jq ".[] | select(.body | startswith(\"$marker\")) | .id" | head -n1) + if [ -n "$id" ]; then + gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -F body=@architecture-report.md + else + gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@architecture-report.md + fi + + - name: Fail on new problems + if: steps.check.outputs.status != '0' + run: exit 1 + test: timeout-minutes: 4 strategy: diff --git a/AGENTS.md b/AGENTS.md index f665220a1..59a79a376 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Commands -`make help` lists all. Key: `check` (format-check + typecheck + test), `test [TEST=path]`, `typecheck`, `format-check`, `replay`, `topology`, `topology-diff`. Extra flags: `ARGS="..."`. +`make help` lists all. Key: `check` (format-check + typecheck + test), `test [TEST=path]`, `typecheck`, `format-check`, `replay`, `topology`, `topology-diff`, `topology-check`. Extra flags: `ARGS="..."`. ## Before Finishing Lua Changes (Mandatory) @@ -33,4 +33,5 @@ Use scanner, not grep, for architecture/debt. Remove debt only after code + test - `make topology`: gap vs target policy. - `make topology-diff ARGS="--from main --to HEAD"`: improved/regressed/neutral. +- `make topology-check`: CI gate vs `origin/main`; fails on new layer-rule breaks, new/grown import loops, or modules missing from `topology.jsonc`. - Flags: `--json`, `--snapshot `. Docs: `scripts/dependency-topology/README.md`. diff --git a/Makefile b/Makefile index 51d502eea..04fd144e5 100644 --- a/Makefile +++ b/Makefile @@ -9,12 +9,16 @@ PYTHON ?= python3 LUA_GLOBS := -g '*.lua' -g '!deps/' TOPOLOGY := scripts/dependency-topology/scan_topology.py +TOPOLOGY_REQUIREMENTS := scripts/dependency-topology/requirements.txt +# uv fetches the scanner's Python deps on demand; without uv, install them into $(PYTHON) first. +TOPOLOGY_PYTHON ?= $(if $(shell command -v uv),uv run --quiet --no-project --with-requirements $(TOPOLOGY_REQUIREMENTS) python3,$(PYTHON)) +TOPOLOGY_BASE ?= origin/main -.PHONY: help check test test-minimal test-unit test-replay typecheck format-check format replay replay-regenerate topology topology-diff +.PHONY: help check test test-minimal test-unit test-replay typecheck format-check format replay replay-regenerate topology topology-diff topology-check help: ## List development commands @awk 'BEGIN { FS = ":.*## " } /^[a-z-]+:.*## / { printf " %-20s %s\n", $$1, $$2 }' $(MAKEFILE_LIST) - @printf '\nExamples:\n make test TEST=tests/unit/formatter_spec.lua\n make test TEST=unit FILTER="Timer"\n make typecheck ARGS="-f github"\n make replay ARGS="-c ReplayAll"\n make replay-regenerate FILE=v2/formatters.json\n make topology ARGS="--json"\n make topology-diff ARGS="--from main --to HEAD --json"\n' + @printf '\nExamples:\n make test TEST=tests/unit/formatter_spec.lua\n make test TEST=unit FILTER="Timer"\n make typecheck ARGS="-f github"\n make replay ARGS="-c ReplayAll"\n make replay-regenerate FILE=v2/formatters.json\n make topology ARGS="--json"\n make topology-diff ARGS="--from main --to HEAD --json"\n make topology-check TOPOLOGY_BASE=upstream/main\n' check: format-check typecheck test ## Run formatting, type checks, and all tests @@ -46,7 +50,10 @@ replay-regenerate: ## Regenerate snapshots with confirmation (optional FILE rela env -u VIM -u VIMRUNTIME ./tests/manual/regenerate_expected.sh $(if $(FILE),"$(FILE)") topology: ## Scan dependency topology (optional scanner ARGS) - $(PYTHON) $(TOPOLOGY) scan $(ARGS) + $(TOPOLOGY_PYTHON) $(TOPOLOGY) scan $(ARGS) topology-diff: ## Compare dependency topology snapshots (optional scanner ARGS) - $(PYTHON) $(TOPOLOGY) diff $(ARGS) + $(TOPOLOGY_PYTHON) $(TOPOLOGY) diff $(ARGS) + +topology-check: ## Fail on new layer-rule breaks, import loops, or modules without a layer (vs TOPOLOGY_BASE) + $(TOPOLOGY_PYTHON) $(TOPOLOGY) diff --from $(TOPOLOGY_BASE) --to worktree --check $(ARGS) diff --git a/scripts/dependency-topology/README.md b/scripts/dependency-topology/README.md index 45ac2b222..341fe8048 100644 --- a/scripts/dependency-topology/README.md +++ b/scripts/dependency-topology/README.md @@ -13,6 +13,40 @@ scripts/dependency-topology/ └── topology.jsonc # Group definitions + review comments (strategy file) ``` +## What it checks + +Every Lua module belongs to a layer (`topology.jsonc`). Imports (`require()`) +may only point down the layers: Entry → Dispatch → Capabilities → Infrastructure, +with Foundation helpers usable from anywhere. The scanner reports: + +- **layer-rule breaks** — an import pointing the wrong way; +- **import loops** — modules that depend on each other in a circle; +- **modules without a layer** — not matched by any pattern, so their imports are not checked. + +Most new files are covered by wildcards (`opencode.services.*`, `opencode.ui.renderer.*`). +Only files outside them need a new entry in `topology.jsonc`. + +## CI and `make topology-check` + +The `architecture check` job runs on every pull request: + +```bash +make topology-check # vs origin/main, worktree included +make topology-check TOPOLOGY_BASE=upstream/main +``` + +It fails on **new** layer-rule breaks, **new or grown** import loops, and **any** +module without a layer. Existing debt is reported but does not fail the build. +The job posts (and updates) a Markdown report comment on the PR and in the job +summary (`diff --check --markdown`). + +Python dependencies are in `requirements.txt`. With [uv](https://docs.astral.sh/uv/) +installed, the `make` targets fetch them automatically; otherwise run +`pip install -r scripts/dependency-topology/requirements.txt`. + +The current `topology.jsonc` is applied to both sides of a diff, so a mapping +change shows up on both snapshots rather than as a regression. + ## Quick Start ```bash diff --git a/scripts/dependency-topology/scan_analysis.py b/scripts/dependency-topology/scan_analysis.py index dd13a23a5..ccf51585c 100644 --- a/scripts/dependency-topology/scan_analysis.py +++ b/scripts/dependency-topology/scan_analysis.py @@ -205,10 +205,13 @@ def build_scan_payload(repo: Path, snapshot: str, strategy: Dict[str, Any], top_ group_rules = build_group_rules(strategy.get("groups", {})) group_cache: Dict[str, str] = {} grouped_counts: Dict[str, int] = {} + ungrouped_modules: List[str] = [] - for m in graph.nodes.keys(): + for m in sorted(graph.nodes.keys()): g = group_of(m, group_rules, group_cache) grouped_counts[g] = grouped_counts.get(g, 0) + 1 + if g == "ungrouped": + ungrouped_modules.append(m) edge_rows = [ { @@ -298,6 +301,7 @@ def scc_severity(size: int) -> str: "count": ungrouped, # If > 0, some modules are not covered by topology.jsonc — # their dependencies are invisible to policy checking. + "modules": ungrouped_modules, }, }, @@ -411,6 +415,9 @@ def build_diff_payload(repo: Path, from_snapshot: str, to_snapshot: str, strateg "violations_fixed": fixed, "violations_new": new, + # Unmapped modules escape every policy rule, so the target snapshot must have none. + "ungrouped_modules": th["ungrouped"]["modules"], + "scc_changes": scc_changes, "edge_changes": { diff --git a/scripts/dependency-topology/scan_topology.py b/scripts/dependency-topology/scan_topology.py index 004ebfafb..0b2441a1b 100644 --- a/scripts/dependency-topology/scan_topology.py +++ b/scripts/dependency-topology/scan_topology.py @@ -95,6 +95,73 @@ def _diff_default_snapshots(repo: Path) -> tuple[str, str]: return "HEAD~1", "HEAD" +def diff_regressions(payload: dict) -> list[str]: + """Changes that fail `diff --check`: new violations, unmapped modules, new or grown cycles.""" + problems = [] + for v in payload["violations_new"]: + problems.append(f"`{v['src']}` imports `{v['dst']}`, which breaks rule `{v['rule']}`") + for m in payload["ungrouped_modules"]: + problems.append(f"`{m}` has no layer in topology.jsonc, so its imports are not checked") + for ch in payload.get("scc_changes", []): + if "new_cycle" in ch: + problems.append("new import loop between: " + ", ".join(f"`{m}`" for m in ch["new_cycle"])) + elif ch.get("gained_members"): + problems.append("import loop now also includes: " + ", ".join(f"`{m}`" for m in ch["gained_members"])) + return problems + + +REPORT_MARKER = "" + + +def render_markdown(payload: dict, problems: list[str]) -> str: + hc = payload["health_comparison"] + + def row(label: str, before: int, after: int) -> str: + delta = after - before + return f"| {label} | {before} | {after} | {delta:+d} |" + + lines = [ + REPORT_MARKER, + "## 🏗️ Architecture check", + "", + "Checks that `require()` calls between modules follow the layer rules in " + "`scripts/dependency-topology/topology.jsonc` " + "(entry → dispatch → services/UI → server/protocols; shared helpers usable everywhere).", + "", + f"Compared `{payload['from_snapshot']}` → `{payload['to_snapshot']}`.", + "", + "| | Before | After | Δ |", + "|---|---|---|---|", + row("Imports breaking layer rules", hc["violations"]["from"], hc["violations"]["to"]), + row("Import loops", hc["cycles"]["count"]["from"], hc["cycles"]["count"]["to"]), + row("Modules in largest loop", hc["cycles"]["largest"]["from"], hc["cycles"]["largest"]["to"]), + row("Modules without a layer", hc["ungrouped"]["from"], hc["ungrouped"]["to"]), + "", + f"Imports between modules: +{payload['edge_changes']['added']} / -{payload['edge_changes']['removed']}", + "", + ] + if problems: + lines.append(f"### ❌ {len(problems)} problem(s) to fix") + lines.append("") + lines.extend(f"- {p}" for p in problems) + lines.append("") + lines.append( + "To fix: give new modules a layer in `topology.jsonc`, move the import so it points " + "down the layers, or list it as an `allowed` exception with a reason." + ) + else: + lines.append("### ✅ No new problems") + fixed = payload["violations_fixed"] + if fixed: + lines.append("") + lines.append(f"### Fixed layer-rule breaks ({len(fixed)})") + lines.append("") + lines.extend(f"- [{v['rule']}] `{v['src']}` → `{v['dst']}`" for v in fixed) + lines.append("") + lines.append("Run locally: `make topology-check`") + return "\n".join(lines) + "\n" + + def cmd_diff(args: argparse.Namespace) -> int: repo = Path(args.repo).resolve() if args.from_snapshot or args.to_snapshot: @@ -106,10 +173,15 @@ def cmd_diff(args: argparse.Namespace) -> int: init_policy(strategy.get("policy", {}).get("rules", [])) payload = build_diff_payload(repo, from_snap, to_snap, strategy) + problems = diff_regressions(payload) + status = 1 if args.check and problems else 0 if args.json: print(json.dumps(payload, indent=2, ensure_ascii=False)) - return 0 + return status + if args.markdown: + print(render_markdown(payload, problems), end="") + return status # Human-readable — show meaningful detail, not just counts hc = payload["health_comparison"] @@ -169,7 +241,17 @@ def cmd_diff(args: argparse.Namespace) -> int: print(" No violation changes.") print() - return 0 + ungrouped = payload["ungrouped_modules"] + if ungrouped: + print(f" Unmapped modules ({len(ungrouped)}) — add them to topology.jsonc:") + for m in ungrouped: + print(f" ? {m}") + print() + + if args.check: + print(f" check: {'FAILED, ' + str(len(problems)) + ' regression(s)' if problems else 'passed'}") + + return status def main() -> int: parser = argparse.ArgumentParser( @@ -183,6 +265,7 @@ def main() -> int: %(prog)s diff Compare HEAD vs uncommitted changes %(prog)s diff --from HEAD~5 Compare 5 commits ago vs uncommitted %(prog)s diff --from v1.0 --to HEAD Compare two commits + %(prog)s diff --from origin/main --check --markdown CI gate + PR report Policy rules are defined in topology.jsonc. Edit that file to customize layer definitions and forbidden dependency directions. @@ -211,6 +294,12 @@ def main() -> int: p_diff.add_argument("--from", dest="from_snapshot", help="Base ref (default: HEAD)") p_diff.add_argument("--to", dest="to_snapshot", help="Target ref (default: worktree = uncommitted changes)") p_diff.add_argument("--json", action="store_true", help="Output JSON instead of summary") + p_diff.add_argument("--markdown", action="store_true", help="Output a Markdown report (for PR comments)") + p_diff.add_argument( + "--check", + action="store_true", + help="Exit 1 on new violations, unmapped modules, or new/grown cycles", + ) p_diff.add_argument("--strategy", help="Path to strategy JSONC (default: topology.jsonc)") args = parser.parse_args() diff --git a/scripts/dependency-topology/topology.jsonc b/scripts/dependency-topology/topology.jsonc index 5e554953f..62046c38d 100644 --- a/scripts/dependency-topology/topology.jsonc +++ b/scripts/dependency-topology/topology.jsonc @@ -25,8 +25,10 @@ // ALL depths (e.g. opencode.ui.renderer.flush). First match wins // across groups, so put specific patterns before wildcards. // -// STATUS: Draft. Needs owner review. Some placements are debatable — see -// inline "REVIEW:" comments. +// STATUS: Enforced in CI (`make topology-check`). Every module must match a +// group; new layer-rule breaks and import loops fail the PR. Open +// placement questions are marked with inline "REVIEW:" comments. + { "groups": { @@ -117,6 +119,11 @@ "opencode.ui.winbar", // window bar "opencode.ui.navigation", // cursor navigation "opencode.ui.diff_tab", // diff view + "opencode.ui.session_diff", // session diff review UI (opened by git_review) + // REVIEW: violates no_capabilities_to_entry + // (session_diff, comment_input → keymap for + // setup_window_keymaps). Move that helper down. + "opencode.ui.session_diff.*", "opencode.ui.dialog", // dialog windows "opencode.ui.completion", // completion engine "opencode.ui.completion.*", @@ -175,6 +182,9 @@ "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.shape", // runtime schema validator (pure, no dependencies) + "opencode.review_anchor", // review-comment anchor math (pure, util only) + "opencode.session_patch", // session patch parsing (pure, no dependencies) "opencode.slash_commands", // built-in slash command metadata // UI primitives — pure data or framework, no business logic From 6584aa4da932ed6353b4f5d4063b55b0587ba82d Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 14 Oct 2026 10:22:30 -0400 Subject: [PATCH 2/3] refactor(keymap): move mapping engine into UI layer Defer protocol message defaults to messaging service --- lua/opencode/keymap.lua | 133 ++---------------- lua/opencode/protocols/observation.lua | 11 +- lua/opencode/protocols/v1/observation.lua | 26 +--- lua/opencode/services/messaging.lua | 11 ++ lua/opencode/ui/formatter/utils.lua | 2 +- lua/opencode/ui/keymap.lua | 113 +++++++++++++++ lua/opencode/ui/session_diff.lua | 2 +- .../ui/session_diff/comment_input.lua | 2 +- scripts/dependency-topology/topology.jsonc | 4 +- tests/unit/keymap_spec.lua | 27 +++- tests/unit/protocol_v1_observation_spec.lua | 17 +++ tests/unit/services_messaging_spec.lua | 52 +++++++ tests/unit/services_spec_support.lua | 8 ++ 13 files changed, 257 insertions(+), 151 deletions(-) create mode 100644 lua/opencode/ui/keymap.lua diff --git a/lua/opencode/keymap.lua b/lua/opencode/keymap.lua index 8e1e0f71b..9d46f9fbe 100644 --- a/lua/opencode/keymap.lua +++ b/lua/opencode/keymap.lua @@ -1,113 +1,20 @@ local M = {} local store = require('opencode.state.store') +local mapper = require('opencode.ui.keymap') local window_keymaps = {} -local function normalize_lhs(lhs) - return vim.api.nvim_replace_termcodes(lhs, true, true, true) -end - -local function is_completion_visible() - return require('opencode.ui.completion').is_completion_visible() -end - ----@param key_binding string The key binding to feed if completion is visible ----@param callback function The callback to execute if completion is not visible -local function wrap_with_completion_check(key_binding, callback) - return function() - if is_completion_visible() then - return vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(key_binding, true, false, true), 'n', false) - end - return callback() - end -end - ----@param func_name string|function ----@param func_args any ----@param actions? table Buffer-local overrides take precedence over panel commands ----@return function|nil -local function resolve_callback(func_name, func_args, actions) - if type(func_name) == 'string' and actions and actions[func_name] then - func_name = actions[func_name] - end - if type(func_name) == 'function' then - return function() - local args = func_args and (type(func_args) == 'table' and vim.deepcopy(func_args) or { func_args }) or {} - return func_name(unpack(args)) - end - end - - if type(func_name) == 'string' then - local commands = require('opencode.commands') - local command_defs = commands.get_commands() - if command_defs[func_name] then - return function() - local args = func_args and (type(func_args) == 'table' and vim.deepcopy(func_args) or { func_args }) or {} - local parsed = commands.build_parsed_intent(func_name, args) - return commands.execute_parsed_intent(parsed) - end - end - - vim.notify('Cannot find keymap action: ' .. func_name, vim.log.levels.ERROR) - return nil - end - - return nil -end - ----@param keymap_config table The keymap configuration table ----@param default_modes table Default modes for these keymaps ----@param base_opts table Base options to use for all keymaps ----@param preserve_existing? boolean ----@param actions? table -local function process_keymap_entry(keymap_config, default_modes, base_opts, preserve_existing, actions) - -- Commands load diff handlers, which also use this mapper for their buffers. +mapper.resolve_command = function(name, args) local commands = require('opencode.commands') - local command_defs = commands.get_commands() - - for key_binding, config_entry in pairs(keymap_config) do - if config_entry == false then - -- Skip keymap if explicitly set to false (disabled) - elseif config_entry then - local func_name = config_entry[1] - local func_args = config_entry[2] - local callback = resolve_callback(func_name, func_args, actions) - - local modes = config_entry.mode or default_modes - if preserve_existing and base_opts.buffer then - local missing_modes = {} - for _, mode in ipairs(type(modes) == 'table' and modes or { modes }) do - local exists = false - for _, mapping in ipairs(vim.api.nvim_buf_get_keymap(base_opts.buffer, mode)) do - if normalize_lhs(mapping.lhs) == normalize_lhs(key_binding) then - exists = true - break - end - end - if not exists then - table.insert(missing_modes, mode) - end - end - modes = missing_modes - end - local opts = vim.tbl_deep_extend('force', {}, base_opts) - opts.nowait = config_entry.nowait - local command_desc = type(func_name) == 'string' - and not (actions and actions[func_name]) - and vim.tbl_get(command_defs, func_name, 'desc') - opts.desc = config_entry.desc or command_desc or '' - - if not callback then - if type(func_name) ~= 'string' then - vim.notify(string.format('No action found for keymap: %s -> %s', key_binding, func_name), vim.log.levels.WARN) - end - elseif #modes > 0 then - if config_entry.defer_to_completion then - callback = wrap_with_completion_check(key_binding, callback) - end - vim.keymap.set(modes, key_binding, callback, opts) - end - end + local definition = commands.get_commands()[name] + if not definition then + vim.notify('Cannot find keymap action: ' .. name, vim.log.levels.ERROR) + return nil end + return function() + local arguments = args and (type(args) == 'table' and vim.deepcopy(args) or { args }) or {} + return commands.execute_parsed_intent(commands.build_parsed_intent(name, arguments)) + end, + definition.desc end local function setup_panel_keymaps(_, windows, previous) @@ -123,14 +30,14 @@ local function setup_panel_keymaps(_, windows, previous) and vim.api.nvim_buf_is_valid(buf) and (name == 'tab_strip' or win and vim.api.nvim_win_is_valid(win)) then - M.setup_window_keymaps(window_keymaps[name .. '_window'], buf, true) + mapper.setup_window_keymaps(window_keymaps[name .. '_window'], buf, true) end end end ----@param keymap OpencodeKeymap The keymap configuration table +---@param keymap OpencodeKeymap function M.setup(keymap) - process_keymap_entry(keymap.editor or {}, { 'n', 'v' }, { silent = false }) + mapper.setup_keymaps(keymap.editor or {}, { 'n', 'v' }, { silent = false }) window_keymaps = keymap store.subscribe('windows', setup_panel_keymaps) setup_panel_keymaps(nil, store.get('windows')) @@ -141,16 +48,4 @@ function M.teardown() window_keymaps = {} end ----@param keymap_config table Window keymap configuration ----@param buf_id integer Buffer ID to set keymaps for ----@param preserve_existing? boolean ----@param actions? table Local action overrides; unknown names use command resolution and its error notification -function M.setup_window_keymaps(keymap_config, buf_id, preserve_existing, actions) - if not vim.api.nvim_buf_is_valid(buf_id) then - return - end - - process_keymap_entry(keymap_config or {}, { 'n' }, { silent = true, buffer = buf_id }, preserve_existing, actions) -end - return M diff --git a/lua/opencode/protocols/observation.lua b/lua/opencode/protocols/observation.lua index c6b3927eb..7e2ecb2dd 100644 --- a/lua/opencode/protocols/observation.lua +++ b/lua/opencode/protocols/observation.lua @@ -58,6 +58,14 @@ local resource_names = { ---@field on_stream_error? fun(observation: OpencodeObservation, message: string) ---@field on_close? fun(observation: OpencodeObservation) +---@class OpencodeMessageSelection +---@field mode? string +---@field model? string +---@field variant? string +---@field default_mode? string +---@field default_model? string Server config model, used when no model is selected +---@field available_agents? string[] Primary agents; only these may update the tab mode + ---@class OpencodeObservation ---@field _connection table ---@field _session_id string @@ -70,7 +78,8 @@ local resource_names = { ---@field _event_revisions table ---@field reply_permission fun(self: OpencodeObservation, request_id: string, answer: {choice: 'once'|'always'|'reject', message?: string}): Promise ---@field validate_message_options fun(self: OpencodeObservation, opts: SendMessageOpts, default_system?: string) ----@field prepare_message fun(self: OpencodeObservation, opts: SendMessageOpts, selected: {mode?: string, model?: string, variant?: string, default_mode?: string, default_model?: string, available_agents?: string[]}): table, OpencodeSessionTabModelUpdate +---@field prepare_message fun(self: OpencodeObservation, opts: SendMessageOpts, selected: OpencodeMessageSelection): table, OpencodeSessionTabModelUpdate +---@field needs_message_defaults? boolean Caller must fill default_model/available_agents in the selection local Observation = {} Observation.__index = Observation diff --git a/lua/opencode/protocols/v1/observation.lua b/lua/opencode/protocols/v1/observation.lua index c8f30b0ee..86215f482 100644 --- a/lua/opencode/protocols/v1/observation.lua +++ b/lua/opencode/protocols/v1/observation.lua @@ -16,42 +16,27 @@ local lifecycle = require('opencode.protocols.observation') 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 = {} ---@param opts SendMessageOpts ----@param selected {mode?: string, model?: string, variant?: string, default_mode?: string} +---@param selected OpencodeMessageSelection ---@return table, OpencodeSessionTabModelUpdate function M.prepare_message(opts, selected) if opts.skills and #opts.skills > 0 then error('V1 submit does not support native skill attachments') end - ---@type {mode?: string, model?: string, variant?: string, default_mode?: string, default_model?: string, available_agents?: string[]} - local message_selection = { - mode = selected.mode, - model = selected.model, - variant = selected.variant, - default_mode = selected.default_mode, - } - if opts.model == nil and selected.model == nil then - local remote_config = config_file.get_opencode_config():await() - message_selection.default_model = remote_config and remote_config.model ~= '' and remote_config.model or nil - end - if opts.agent or message_selection.mode or message_selection.default_mode then - message_selection.available_agents = config_file.get_opencode_agents():await() - end local explicit_model = opts.model ~= nil if opts.agent == nil then - opts.agent = message_selection.mode or message_selection.default_mode + opts.agent = selected.mode or selected.default_mode end if opts.model == nil then - opts.model = message_selection.model or message_selection.default_model + opts.model = selected.model or selected.default_model end if opts.variant == nil then - opts.variant = message_selection.variant + opts.variant = selected.variant end local overrides = {} @@ -74,7 +59,7 @@ function M.prepare_message(opts, selected) end if opts.agent then overrides.agent = opts.agent - if vim.tbl_contains(message_selection.available_agents or {}, opts.agent) then + if vim.tbl_contains(selected.available_agents or {}, opts.agent) then update.mode = opts.agent end end @@ -1049,6 +1034,7 @@ function M.new(connection, ref) observation._v1_history_complete = false observation._v1_history_limit = 50 observation._v1_older_loading = false + observation.needs_message_defaults = true function observation.prepare_message(_, opts, selected) return M.prepare_message(opts, selected) end diff --git a/lua/opencode/services/messaging.lua b/lua/opencode/services/messaging.lua index 249432625..dfc9c4652 100644 --- a/lua/opencode/services/messaging.lua +++ b/lua/opencode/services/messaging.lua @@ -2,6 +2,7 @@ local state = require('opencode.state') local context = require('opencode.context') local util = require('opencode.util') local config = require('opencode.config') +local config_file = require('opencode.config_file') local Promise = require('opencode.promise') local log = require('opencode.log') local session_runtime = require('opencode.services.session_runtime') @@ -56,12 +57,22 @@ local function prepare_message(observation, prompt, opts) end, submission_context.review_comments or {}) submission_context.automatic_context = {} local previous_context = state.last_sent_context and vim.deepcopy(state.last_sent_context) + ---@type OpencodeMessageSelection local selected = { mode = state.current_mode, model = state.current_model, variant = state.current_variant, default_mode = config.default_mode, } + if observation.needs_message_defaults then + if opts.model == nil and selected.model == nil then + local remote_config = config_file.get_opencode_config():await() + selected.default_model = remote_config and remote_config.model ~= '' and remote_config.model or nil + end + if opts.agent or selected.mode or selected.default_mode then + selected.available_agents = config_file.get_opencode_agents():await() + end + end local overrides, model_update = observation:prepare_message(opts, selected) local params = context .format_message(prompt, opts.context, { diff --git a/lua/opencode/ui/formatter/utils.lua b/lua/opencode/ui/formatter/utils.lua index 160d701c7..5548262f2 100644 --- a/lua/opencode/ui/formatter/utils.lua +++ b/lua/opencode/ui/formatter/utils.lua @@ -132,7 +132,7 @@ function M.add_tool_diff_action(output, message, context, path, first_line, last if not context or not context.interactive or not message then return end - local connection = require('opencode.state').opencode_server + local connection = require('opencode.state.store').get('opencode_server') if not connection or connection.protocol ~= 'v2' then return end diff --git a/lua/opencode/ui/keymap.lua b/lua/opencode/ui/keymap.lua new file mode 100644 index 000000000..adef3b55f --- /dev/null +++ b/lua/opencode/ui/keymap.lua @@ -0,0 +1,113 @@ +local M = {} + +---@type fun(name: string, args: any): function?, string? +M.resolve_command = function(name, _args) + vim.notify( + 'Keymap command routing not initialized; cannot resolve action: ' .. name .. ' (require opencode.keymap first)', + vim.log.levels.ERROR + ) +end + +local function normalize_lhs(lhs) + return vim.api.nvim_replace_termcodes(lhs, true, true, true) +end + +local function is_completion_visible() + return require('opencode.ui.completion').is_completion_visible() +end + +---@param key_binding string The key binding to feed if completion is visible +---@param callback function The callback to execute if completion is not visible +local function wrap_with_completion_check(key_binding, callback) + return function() + if is_completion_visible() then + return vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(key_binding, true, false, true), 'n', false) + end + return callback() + end +end + +---@param func_name string|function +---@param func_args any +---@param actions? table Buffer-local overrides take precedence over panel commands +---@return function|nil, string? +local function resolve_callback(func_name, func_args, actions) + if type(func_name) == 'string' and actions and actions[func_name] then + func_name = actions[func_name] + end + if type(func_name) == 'function' then + return function() + local args = func_args and (type(func_args) == 'table' and vim.deepcopy(func_args) or { func_args }) or {} + return func_name(unpack(args)) + end + end + + if type(func_name) == 'string' then + return M.resolve_command(func_name, func_args) + end + + return nil +end + +---@param keymap_config table The keymap configuration table +---@param default_modes table Default modes for these keymaps +---@param base_opts table Base options to use for all keymaps +---@param preserve_existing? boolean +---@param actions? table +function M.setup_keymaps(keymap_config, default_modes, base_opts, preserve_existing, actions) + for key_binding, config_entry in pairs(keymap_config) do + if config_entry == false then + -- Skip keymap if explicitly set to false (disabled) + elseif config_entry then + local func_name = config_entry[1] + local func_args = config_entry[2] + local callback, command_desc = resolve_callback(func_name, func_args, actions) + + local modes = config_entry.mode or default_modes + if preserve_existing and base_opts.buffer then + local missing_modes = {} + for _, mode in ipairs(type(modes) == 'table' and modes or { modes }) do + local exists = false + for _, mapping in ipairs(vim.api.nvim_buf_get_keymap(base_opts.buffer, mode)) do + if normalize_lhs(mapping.lhs) == normalize_lhs(key_binding) then + exists = true + break + end + end + if not exists then + table.insert(missing_modes, mode) + end + end + modes = missing_modes + end + local opts = vim.tbl_deep_extend('force', {}, base_opts) + opts.nowait = config_entry.nowait + opts.desc = config_entry.desc or command_desc or '' + + if not callback then + if type(func_name) ~= 'string' then + vim.notify(string.format('No action found for keymap: %s -> %s', key_binding, func_name), vim.log.levels.WARN) + end + elseif #modes > 0 then + if config_entry.defer_to_completion then + callback = wrap_with_completion_check(key_binding, callback) + end + vim.keymap.set(modes, key_binding, callback, opts) + end + end + end +end + +---@param keymap_config table Window keymap configuration +---@param buf_id integer Buffer ID to set keymaps for +---@param preserve_existing? boolean +---@param actions? table Local action overrides; unknown names use command resolution and its error notification +function M.setup_window_keymaps(keymap_config, buf_id, preserve_existing, actions) + if not vim.api.nvim_buf_is_valid(buf_id) then + return + end + + M.setup_keymaps(keymap_config or {}, { 'n' }, { silent = true, buffer = buf_id }, preserve_existing, actions) +end + +return M diff --git a/lua/opencode/ui/session_diff.lua b/lua/opencode/ui/session_diff.lua index e5414da1b..c94c1ec89 100644 --- a/lua/opencode/ui/session_diff.lua +++ b/lua/opencode/ui/session_diff.lua @@ -2,7 +2,7 @@ local patch = require('opencode.session_patch') local config = require('opencode.config') local float_layout = require('opencode.ui.float_layout') local help = require('opencode.ui.session_diff.help') -local keymaps = require('opencode.keymap') +local keymaps = require('opencode.ui.keymap') local icons = require('opencode.ui.icons') local render = require('opencode.ui.session_diff.render') local context = require('opencode.context') diff --git a/lua/opencode/ui/session_diff/comment_input.lua b/lua/opencode/ui/session_diff/comment_input.lua index 0b9635161..676dd2611 100644 --- a/lua/opencode/ui/session_diff/comment_input.lua +++ b/lua/opencode/ui/session_diff/comment_input.lua @@ -1,5 +1,5 @@ local config = require('opencode.config') -local keymaps = require('opencode.keymap') +local keymaps = require('opencode.ui.keymap') local M = {} diff --git a/scripts/dependency-topology/topology.jsonc b/scripts/dependency-topology/topology.jsonc index 62046c38d..a22f4a0ad 100644 --- a/scripts/dependency-topology/topology.jsonc +++ b/scripts/dependency-topology/topology.jsonc @@ -120,12 +120,10 @@ "opencode.ui.navigation", // cursor navigation "opencode.ui.diff_tab", // diff view "opencode.ui.session_diff", // session diff review UI (opened by git_review) - // REVIEW: violates no_capabilities_to_entry - // (session_diff, comment_input → keymap for - // setup_window_keymaps). Move that helper down. "opencode.ui.session_diff.*", "opencode.ui.dialog", // dialog windows "opencode.ui.completion", // completion engine + "opencode.ui.keymap", // mapping engine; command routing injected by entry "opencode.ui.completion.*", "opencode.ui.context_bar", // context display bar "opencode.ui.reference_picker", // reference browser diff --git a/tests/unit/keymap_spec.lua b/tests/unit/keymap_spec.lua index 9348a139c..6ed8ead57 100644 --- a/tests/unit/keymap_spec.lua +++ b/tests/unit/keymap_spec.lua @@ -15,6 +15,7 @@ describe('opencode.keymap', function() local mock_commands local mock_completion local keymap + local mapper local built_parsed local executed_parsed local toggle_calls @@ -117,6 +118,7 @@ describe('opencode.keymap', function() end keymap = require('opencode.keymap') + mapper = require('opencode.ui.keymap') end) after_each(function() @@ -133,6 +135,7 @@ describe('opencode.keymap', function() vim.api.nvim_feedkeys = original_nvim_feedkeys package.loaded['opencode.keymap'] = nil + package.loaded['opencode.ui.keymap'] = nil package.loaded['opencode.api'] = nil package.loaded['opencode.commands'] = nil package.loaded['opencode.ui.completion'] = nil @@ -141,10 +144,24 @@ describe('opencode.keymap', function() end) describe('local actions', function() + it('mounts local actions without consulting command routing', function() + mapper.resolve_command = function() + error('local mappings must not load commands') + end + local called = false + mapper.setup_window_keymaps({ x = { 'local_action' } }, 0, false, { + local_action = function() + called = true + end, + }) + set_keymaps[1].callback() + assert.is_true(called) + end) + it('prefers local overrides and passes fresh arguments on each invocation', function() local arguments = { { value = 'original' } } local received = {} - keymap.setup_window_keymaps({ x = { 'toggle', arguments, mode = 'n', nowait = true } }, 0, false, { + mapper.setup_window_keymaps({ x = { 'toggle', arguments, mode = 'n', nowait = true } }, 0, false, { toggle = function(value) received[#received + 1] = value.value value.value = 'changed' @@ -160,14 +177,14 @@ describe('opencode.keymap', function() end) it('falls back to panel commands for names outside the local action table', function() - keymap.setup_window_keymaps({ x = { 'toggle' } }, 0, false, {}) + mapper.setup_window_keymaps({ x = { 'toggle' } }, 0, false, {}) set_keymaps[1].callback() assert.equals('toggle', executed_parsed[1].intent.name) end) it('passes configured arguments to custom callbacks', function() local received - keymap.setup_window_keymaps({ + mapper.setup_window_keymaps({ x = { function(value) received = value @@ -419,7 +436,7 @@ describe('opencode.keymap', function() describe('setup_window_keymaps', function() it('routes input-window string actions through ParsedIntent + execute_parsed_intent', function() local bufnr = vim.api.nvim_create_buf(false, true) - keymap.setup_window_keymaps({ [''] = { 'submit_input_prompt' } }, bufnr) + mapper.setup_window_keymaps({ [''] = { 'submit_input_prompt' } }, bufnr) assert.equal(1, #set_keymaps) set_keymaps[1].callback() @@ -463,7 +480,7 @@ describe('opencode.keymap', function() original_keymap_set('n', lhs, function() end, { buffer = bufnr }) end - keymap.setup_window_keymaps({ + mapper.setup_window_keymaps({ [''] = { function() end }, [''] = { function() end }, [''] = { function() end }, diff --git a/tests/unit/protocol_v1_observation_spec.lua b/tests/unit/protocol_v1_observation_spec.lua index 10e3e9ace..dcf6adc2e 100644 --- a/tests/unit/protocol_v1_observation_spec.lua +++ b/tests/unit/protocol_v1_observation_spec.lua @@ -27,6 +27,23 @@ local function content_by_id(entry, id) end describe('V1 protocol Observation interpretation', function() + it('asks the caller for message defaults and applies them without fetching', function() + local observed = observation('session-defaults') + assert.is_true(observed.needs_message_defaults) + local params, update = observed:prepare_message({}, { + default_mode = 'plan', + default_model = 'provider/default', + available_agents = { 'plan' }, + }) + assert.same({ model = { providerID = 'provider', modelID = 'default' }, agent = 'plan' }, params) + assert.same({ model = 'provider/default', mode = 'plan' }, update) + end) + + it('does not update the tab mode for agents outside the available list', function() + local observed = observation('session-hidden-agent') + local _, update = observed:prepare_message({ agent = 'hidden' }, { available_agents = { 'plan' } }) + assert.is_nil(update.mode) + end) it('validates a complete snapshot before replacing existing entries', function() local observed = observation(fixture().sessionID) local message = fixture().snapshot[2] diff --git a/tests/unit/services_messaging_spec.lua b/tests/unit/services_messaging_spec.lua index 3e96442cb..3e6931833 100644 --- a/tests/unit/services_messaging_spec.lua +++ b/tests/unit/services_messaging_spec.lua @@ -219,6 +219,58 @@ describe('opencode.services.messaging', function() state.model.set_mode(previous_mode) end) + it('does not fetch message defaults for observations that do not need them', function() + state.session.set_active({ id = 'sess-v2' }) + connection.protocol = 'v2' + local config_stub = stub(config_file, 'get_opencode_config') + local agents_stub = stub(config_file, 'get_opencode_agents') + local observation = state.session.active_observation() + local original_submit = observation.submit + observation.submit = function() + return successful_submission() + end + + messaging.send_message('hello'):wait() + + assert.stub(config_stub).was_not_called() + assert.stub(agents_stub).was_not_called() + observation.submit = original_submit + config_stub:revert() + agents_stub:revert() + end) + + it('passes server defaults to observations that need them', function() + state.session.set_active({ id = 'sess1' }) + local previous_model = state.current_model + local previous_mode = state.current_mode + state.model.set_model(nil) + state.model.set_mode('plan') + local config_stub = stub(config_file, 'get_opencode_config').returns(Promise.new():resolve({ model = 'p/default' })) + local agents_stub = stub(config_file, 'get_opencode_agents').returns(Promise.new():resolve({ 'plan' })) + local observation = state.session.active_observation() + local original_prepare = observation.prepare_message + local received + observation.prepare_message = function(self, opts, selected) + received = vim.deepcopy(selected) + return original_prepare(self, opts, selected) + end + local original_submit = observation.submit + observation.submit = function() + return successful_submission() + end + + messaging.send_message('hello'):wait() + + assert.equal('p/default', received.default_model) + assert.same({ 'plan' }, received.available_agents) + observation.prepare_message = original_prepare + observation.submit = original_submit + config_stub:revert() + agents_stub:revert() + state.model.set_model(previous_model) + state.model.set_mode(previous_mode) + end) + it('rejects a V2 default system prompt before submitting', function() state.session.set_active({ id = 'sess-v2' }) connection.protocol = 'v2' diff --git a/tests/unit/services_spec_support.lua b/tests/unit/services_spec_support.lua index 35bd55118..3dc41a0d4 100644 --- a/tests/unit/services_spec_support.lua +++ b/tests/unit/services_spec_support.lua @@ -73,6 +73,14 @@ function M.mock_connection() return require('opencode.protocols.v1.observation').prepare_message(opts, selected) end observation._connection = self + -- Specs flip connection.protocol after observing, so the flag follows it. + setmetatable(observation, { + __index = function(current, key) + if key == 'needs_message_defaults' then + return current._connection.protocol == 'v1' + end + end, + }) function observation:read() return self._state end From 9ba546423bdf33eb9005dc4dab218cf79a5ab801 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 14 Oct 2026 10:31:00 -0400 Subject: [PATCH 3/3] test: make async waits more reliable Increase timeout windows and wait for explicit conditions in protocol runtime and session tab strip tests. --- .../protocol_v1_observation_runtime_spec.lua | 30 +++++++++---------- tests/unit/session_tab_strip_spec.lua | 18 ++++++----- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/tests/unit/protocol_v1_observation_runtime_spec.lua b/tests/unit/protocol_v1_observation_runtime_spec.lua index 4effba46d..5ffabdaec 100644 --- a/tests/unit/protocol_v1_observation_runtime_spec.lua +++ b/tests/unit/protocol_v1_observation_runtime_spec.lua @@ -181,14 +181,14 @@ describe('V1 protocol Observation runtime', function() local connection, server = runtime() local observation = observe(connection, 'ses-reply') local request = observation:request_reply({ text = 'hello', context = {}, files = {}, agents = {} }) - assert.is_true(vim.wait(500, function() + assert.is_true(vim.wait(2000, function() return #server.submits == 1 - end)) + end, 10)) local input_id = server.submits[1].input.messageID server.submits[1].request:resolve(response('ses-reply', 'msg-answer', input_id, 'assistant', nil, 'stop')) - assert.is_true(vim.wait(500, function() + assert.is_true(vim.wait(2000, function() return next(observation._v1_submissions) ~= nil - end)) + end, 10)) assert.is_false(request.promise:is_resolved()) emit(server.streams[1], '/server/project', 'message.updated', { @@ -352,7 +352,7 @@ describe('V1 protocol Observation runtime', function() assert.equals('loading', observation:read().sync.permissions.state) server.permissions[1]:resolve({}) server.messages['ses-sync'][1]:reject('messages unavailable') - assert.is_true(vim.wait(500, function() + assert.is_true(vim.wait(2000, function() return observation:read().sync.permissions.state == 'current' and observation:read().sync.messages.state == 'error' end, 10)) @@ -374,14 +374,14 @@ describe('V1 protocol Observation runtime', function() local unsubscribe_replacement = replacement:watch({ 'messages' }, function() end) local replacement_request = server.messages['ses-replaced'][2] old_request:resolve({}) - assert.is_true(vim.wait(500, function() + assert.is_true(vim.wait(2000, function() return old_request:is_resolved() end, 10)) assert.equals('unread', old:read().sync.messages.state) assert.equals('loading', replacement:read().sync.messages.state) replacement_request:resolve({}) - assert.is_true(vim.wait(500, function() + assert.is_true(vim.wait(2000, function() return replacement:read().sync.messages.state == 'current' end, 10)) assert.equals('current', replacement:read().sync.messages.state) @@ -400,13 +400,13 @@ describe('V1 protocol Observation runtime', function() assert.equals('error', observation:read().sync.messages.state) assert.matches('invalid V1 event JSON', observation:read().sync.messages.error.message) - assert.is_true(vim.wait(500, function() + assert.is_true(vim.wait(2000, function() return #server.streams == 2 and #server.messages['ses-recover'] == 2 end, 10)) assert.equals(server.streams[2], connection._stream) stale_request:resolve({}) server.messages['ses-recover'][2]:resolve({}) - assert.is_true(vim.wait(500, function() + assert.is_true(vim.wait(2000, function() return observation:read().sync.messages.state == 'current' end, 10)) @@ -513,7 +513,7 @@ describe('V1 protocol Observation runtime', function() server.statuses[1]:resolve({ ['ses-events'] = { type = 'busy' } }) server.permissions[1]:resolve({ permission }) server.questions[1]:resolve({ question }) - assert.is_true(vim.wait(500, function() + assert.is_true(vim.wait(2000, function() return #server.sessions['ses-events'] == 2 and #server.children['ses-events'] == 2 and #server.statuses == 2 @@ -525,7 +525,7 @@ describe('V1 protocol Observation runtime', function() server.statuses[2]:resolve({ ['ses-events'] = { type = 'busy' } }) server.permissions[2]:resolve({ permission }) server.questions[2]:resolve({ question }) - assert.is_true(vim.wait(500, function() + assert.is_true(vim.wait(2000, function() for _, resource in ipairs({ 'session', 'children', 'execution', 'permissions', 'questions' }) do if observation:read().sync[resource].state ~= 'current' then return false @@ -685,11 +685,11 @@ describe('V1 protocol Observation runtime', function() 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() + assert.is_true(vim.wait(2000, function() return #server.permissions == 2 end, 10)) server.permissions[2]:resolve({ permission }) - assert.is_true(vim.wait(500, function() + assert.is_true(vim.wait(2000, function() return observation:read().sync.permissions.state == 'current' end, 10)) assert.equals('answered', observation:read().permission_requests_by_id['per-early'].status) @@ -759,7 +759,7 @@ describe('V1 protocol Observation runtime', function() initial[index] = history_message('ses-history', string.format('msg-%03d', index), 'msg-input', 'stop') end server.messages['ses-history'][1]:resolve(initial) - assert.is_true(vim.wait(500, function() + assert.is_true(vim.wait(2000, function() return observation:read().sync.messages.state == 'current' end, 10)) @@ -784,7 +784,7 @@ describe('V1 protocol Observation runtime', function() ) end server.messages['ses-history'][2]:resolve(older) - assert.is_true(vim.wait(500, function() + assert.is_true(vim.wait(2000, function() return #server.messages['ses-history'] == 3 end, 10)) assert.is_nil(observation:read().entries_by_id['msg-old-a']) diff --git a/tests/unit/session_tab_strip_spec.lua b/tests/unit/session_tab_strip_spec.lua index af99af858..05a4eb9aa 100644 --- a/tests/unit/session_tab_strip_spec.lua +++ b/tests/unit/session_tab_strip_spec.lua @@ -132,11 +132,13 @@ describe('opencode session tab strip', function() local marker_column = assert(line:find('%+2')) vim.api.nvim_set_current_win(windows.tab_strip_win) vim.api.nvim_win_set_cursor(windows.tab_strip_win, { 1, marker_column - 1 }) - assert.is_true(vim.wait(200, function() + assert.is_true(vim.wait(2000, function() return vim.fn.maparg('', 'n', false, true).buffer == 1 - end)) + end, 10)) vim.api.nvim_feedkeys(vim.keycode(''), 'xt', false) - vim.wait(20) + assert.is_true(vim.wait(2000, function() + return #picker_stub.calls > 0 + end, 10)) assert.stub(picker_stub).was_called() picker_stub:revert() end) @@ -219,15 +221,17 @@ describe('opencode session tab strip', function() local second = session_tabs.create({ id = 'session-two', title = 'Second' }) session_tabs.activate(second) state.ui.set_windows(windows) - vim.wait(50) + assert.is_true(vim.wait(2000, function() + return windows.tab_strip_win ~= nil + end, 10)) assert.is_not_nil(windows.tab_strip_win) assert.is_true(vim.api.nvim_win_is_valid(windows.tab_strip_win)) assert.matches('Second', vim.api.nvim_buf_get_lines(windows.tab_strip_buf, 0, 1, false)[1]) session_tabs.remove(first) - vim.wait(50) - - assert.is_nil(windows.tab_strip_win) + assert.is_true(vim.wait(2000, function() + return windows.tab_strip_win == nil + end, 10)) end) end)