diff --git a/.agents/skills/lib_py/agents/adapters/opencode.py b/.agents/skills/lib_py/agents/adapters/opencode.py index eacf656..6c3d7ce 100644 --- a/.agents/skills/lib_py/agents/adapters/opencode.py +++ b/.agents/skills/lib_py/agents/adapters/opencode.py @@ -19,6 +19,9 @@ def _ro_connect(db: str): return sqlite3.connect(f"file:{quote(path)}?mode=ro" + ("" if both else "&immutable=1"), uri=True) +# (binary, HOME, XDG_DATA_HOME) -> the db `debug paths` printed for them. +_DB_PATHS = {} + _PROVIDER_ENV_VARS = ( 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', @@ -126,13 +129,23 @@ class OpenCodeAgentAdapter(BaseAgentAdapter): def _resolve_db(self, ctx: DiscoveryContext) -> Optional[str]: # opencode 2.x prints its paths with `debug paths` (the 1.x `db path` # is gone: it prints the help and exits 0). Fallback: the default. + # Each run writes a line to opencode's log, so a process asks once + # per (binary, HOME, XDG_DATA_HOME) the run would see (Issue #60). + bin_path = self._bin(ctx) + if ctx.home_dir: + key = (bin_path, ctx.home_dir, self._data_home(ctx)) + else: + key = (bin_path, os.environ.get("HOME"), os.environ.get("XDG_DATA_HOME")) + if bin_path and key in _DB_PATHS: + return _DB_PATHS[key] try: res = self._run(ctx, ["debug", "paths"], timeout=5) if res is not None and res.returncode == 0: for line in res.stdout.splitlines(): parts = line.split(None, 1) if len(parts) == 2 and parts[0] == "db": - return parts[1].strip() + _DB_PATHS[key] = parts[1].strip() + return _DB_PATHS[key] except Exception: pass return os.path.join(self._data_home(ctx), "opencode", "opencode.db") diff --git a/tests/test_issue60_linux_fixes.py b/tests/test_issue60_linux_fixes.py index 9513f96..15899d0 100644 --- a/tests/test_issue60_linux_fixes.py +++ b/tests/test_issue60_linux_fixes.py @@ -5,6 +5,8 @@ 5. The adapter's queries neither create nor remove anything beside opencode's database (a read-write open checkpoints the WAL and deletes the sidecars on close; a plain mode=ro open creates missing ones). +6. `debug paths` (which writes a line to opencode's log) runs once per + (binary, HOME, XDG_DATA_HOME) in a process. """ from __future__ import annotations @@ -28,6 +30,8 @@ from lib_py import stall_watch # noqa: E402 from lib_py.agents.base import DiscoveryContext # noqa: E402 from lib_py.agents.input_region import extract_input_region # noqa: E402 from lib_py.agents.registry import get_adapter # noqa: E402 +from lib_py.agents.adapters import opencode as opencode_mod # noqa: E402 +import _fake_opencode # noqa: E402 # 140 columns (herdr capture) and 60 columns, where the example loses its # closing quote. @@ -35,6 +39,11 @@ HOME_SCREENS = ("opencode_idle_2x_home.txt", "opencode_idle_2x_home_narrow.txt") PLACEHOLDER_LINE = re.compile(r'^(\s*┃\s*)Ask anything… ".*$', re.M) +@pytest.fixture(autouse=True) +def _no_cached_db_paths(monkeypatch): + monkeypatch.setattr(opencode_mod, "_DB_PATHS", {}, raising=False) + + def _pane(name: str) -> str: return "".join(l for l in (PANES / name).read_text(encoding="utf-8").splitlines(True) if not l.startswith("# ")) @@ -165,3 +174,42 @@ def test_a_query_reads_right_and_leaves_the_sidecars_alone(wal_home, state, list run, expected = QUERIES[query] assert run(adapter, ctx, db) == expected assert sorted(os.listdir(os.path.dirname(db))) == listing + + +# --------------------------------------------------------------------------- +# 6. `debug paths` once per (binary, HOME, XDG_DATA_HOME) +# --------------------------------------------------------------------------- +def test_debug_paths_runs_once_per_home(tmp_path, monkeypatch): + log = tmp_path / "calls.log" + fake = _fake_opencode.write(tmp_path / "fakebin" / "opencode-real") + wrapper = tmp_path / "fakebin" / "opencode" + wrapper.write_text(f'#!/bin/sh\necho "$*" >> "{log}"\nexec "{fake}" "$@"\n') + wrapper.chmod(0o755) + adapter = get_adapter("opencode") + monkeypatch.setattr(adapter, "_bin", lambda ctx: str(wrapper)) + homes = [tmp_path / "home1", tmp_path / "home2"] + ctxs = [DiscoveryContext(workspace=str(tmp_path), agent_name="opencode", home_dir=str(h)) for h in homes] + + def calls(): + return log.read_text().splitlines() if log.exists() else [] + + want = [str(h / ".local" / "share" / "opencode" / "opencode.db") for h in homes] + assert [adapter._resolve_db(ctxs[0]) for _ in range(2)] == [want[0]] * 2 + assert calls() == ["debug paths"] + assert adapter._resolve_db(ctxs[1]) == want[1] + assert adapter._resolve_db(ctxs[0]) == want[0] + assert calls() == ["debug paths"] * 2 + + +def test_a_failed_debug_paths_is_not_remembered(tmp_path, monkeypatch): + log = tmp_path / "calls.log" + failing = tmp_path / "fakebin" / "opencode" + failing.parent.mkdir() + failing.write_text(f'#!/bin/sh\necho "$*" >> "{log}"\nexit 3\n') + failing.chmod(0o755) + adapter = get_adapter("opencode") + monkeypatch.setattr(adapter, "_bin", lambda ctx: str(failing)) + ctx = DiscoveryContext(workspace=str(tmp_path), agent_name="opencode", home_dir=str(tmp_path / "home")) + default = str(tmp_path / "home" / ".local" / "share" / "opencode" / "opencode.db") + assert [adapter._resolve_db(ctx) for _ in range(2)] == [default] * 2 + assert log.read_text().splitlines() == ["debug paths"] * 2