tests/_fake_opencode.py implements the two commands MAM calls (debug paths, session delete) as measured on 2.0.32; unit tests never run a real opencode (it would migrate the test database). test_real_opencode_2x_contract runs a real 2.x opencode, when installed, in a home and tmp dir of its own with --standalone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
229 lines
9.6 KiB
Python
229 lines
9.6 KiB
Python
"""Issue #60: opencode 2.x.
|
|
|
|
Measured on opencode 2.0.32 (macOS): the TUI rejects `--agent`, the database
|
|
keeps sessions in session_v2 (a fresh 2.x database has no `session` table; a
|
|
migrated one keeps the 1.x tables but no longer writes them), `db path` is
|
|
gone (`debug paths` prints the db), and `session delete` removes a session
|
|
with its child sessions from the 2.x tables only.
|
|
|
|
The unit tests use the fake opencode of tests/_fake_opencode.py. The last
|
|
test runs a real opencode 2.x, when one is installed, in a home and tmp dir
|
|
of its own with --standalone (no background service is started or reached).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
SKILLS = REPO_ROOT / ".agents" / "skills"
|
|
sys.path.insert(0, str(SKILLS))
|
|
from lib_py.agents.registry import get_adapter # noqa: E402
|
|
from lib_py.agents.base import DiscoveryContext # noqa: E402
|
|
import _fake_opencode # noqa: E402
|
|
|
|
V2_DDL = ("CREATE TABLE session_v2 (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT NOT NULL, time_created INTEGER NOT NULL)",
|
|
"CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT)",
|
|
"CREATE TABLE event_sequence (aggregate_id TEXT PRIMARY KEY, seq INTEGER)")
|
|
|
|
|
|
@pytest.fixture
|
|
def oc(tmp_path, monkeypatch):
|
|
"""The opencode adapter on a home of its own, with a fake opencode that
|
|
logs every call."""
|
|
home = tmp_path / "home"
|
|
ws = tmp_path / "ws"
|
|
ws.mkdir(parents=True)
|
|
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))
|
|
ctx = DiscoveryContext(workspace=str(ws), agent_name="opencode", home_dir=str(home))
|
|
db = home / ".local" / "share" / "opencode" / "opencode.db"
|
|
db.parent.mkdir(parents=True)
|
|
conn = sqlite3.connect(str(db))
|
|
for stmt in V2_DDL:
|
|
conn.execute(stmt)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
def calls():
|
|
return log.read_text().splitlines() if log.exists() else []
|
|
|
|
return adapter, ctx, db, ws, calls
|
|
|
|
|
|
def _add(db, sid, ws, ms, parent=None):
|
|
conn = sqlite3.connect(str(db))
|
|
conn.execute("INSERT INTO session_v2 VALUES (?, ?, ?, ?)", (sid, parent, str(ws), ms))
|
|
conn.execute("INSERT INTO session_message VALUES (?, ?)", (f"msg-{sid}", sid))
|
|
conn.execute("INSERT INTO event_sequence VALUES (?, 1)", (sid,))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def _rows(db, sid):
|
|
conn = sqlite3.connect(str(db))
|
|
try:
|
|
return (conn.execute("SELECT count(*) FROM session_v2 WHERE id=?", (sid,)).fetchone()[0]
|
|
+ conn.execute("SELECT count(*) FROM session_message WHERE session_id=?", (sid,)).fetchone()[0]
|
|
+ conn.execute("SELECT count(*) FROM event_sequence WHERE aggregate_id=?", (sid,)).fetchone()[0])
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def test_the_db_comes_from_debug_paths(oc):
|
|
adapter, ctx, db, ws, calls = oc
|
|
assert adapter._resolve_db(ctx) == str(db)
|
|
assert calls() == ["debug paths"]
|
|
|
|
|
|
def test_discover_and_verify_read_session_v2_and_skip_child_sessions(oc):
|
|
adapter, ctx, db, ws, calls = oc
|
|
_add(db, "ses_main", ws, 2_000_000)
|
|
_add(db, "ses_child", ws, 3_000_000, parent="ses_main") # a subagent's session: newer, same dir
|
|
_add(db, "ses_old", ws, 500_000)
|
|
ctx.epoch = 1000
|
|
assert adapter.discover(ctx) == ["ses_main"]
|
|
assert adapter.verify_artifact("ses_main", ctx) is True
|
|
assert adapter.verify_artifact("ses_old", ctx) is False
|
|
|
|
|
|
def test_a_fresh_2x_database_has_no_session_table_and_still_works(oc):
|
|
adapter, ctx, db, ws, calls = oc
|
|
_add(db, "ses_main", ws, 2_000_000)
|
|
conn = sqlite3.connect(str(db))
|
|
assert conn.execute("SELECT count(*) FROM sqlite_master WHERE name='session'").fetchone()[0] == 0
|
|
conn.close()
|
|
assert adapter.discover(ctx) == ["ses_main"]
|
|
|
|
|
|
def test_purge_asks_opencode_to_delete_the_session_and_its_children(oc):
|
|
adapter, ctx, db, ws, calls = oc
|
|
_add(db, "ses_main", ws, 2_000_000)
|
|
_add(db, "ses_child", ws, 3_000_000, parent="ses_main")
|
|
_add(db, "ses_other", ws, 2_500_000)
|
|
purged = adapter.purge_artifacts("ses_main", ctx)
|
|
assert purged == ["opencode session (with its child sessions): ses_main"] # no 1.x tables: nothing else
|
|
assert "session delete --standalone -- ses_main" in calls()
|
|
assert _rows(db, "ses_main") == 0 and _rows(db, "ses_child") == 0
|
|
assert _rows(db, "ses_other") == 3
|
|
assert adapter.residual_artifacts("ses_main", ctx) == []
|
|
|
|
|
|
def test_purge_does_not_run_opencode_for_a_session_it_does_not_hold(oc):
|
|
adapter, ctx, db, ws, calls = oc
|
|
assert adapter.purge_artifacts("ses_missing", ctx) == []
|
|
assert not any(c.startswith("session delete") for c in calls())
|
|
|
|
|
|
@pytest.mark.parametrize("broken", ["exit", "missing"])
|
|
def test_a_failed_delete_is_warned_and_reported(oc, monkeypatch, capsys, broken):
|
|
adapter, ctx, db, ws, calls = oc
|
|
_add(db, "ses_main", ws, 2_000_000)
|
|
if broken == "missing":
|
|
monkeypatch.setattr(adapter, "_bin", lambda ctx: None)
|
|
want = "no opencode binary"
|
|
else:
|
|
failing = Path(ctx.home_dir).parent / "fakebin" / "failing"
|
|
failing.write_text('#!/bin/sh\n[ "$1" = debug ] && exec "$(dirname "$0")/opencode-real" "$@"\necho boom >&2\nexit 3\n')
|
|
failing.chmod(0o755)
|
|
monkeypatch.setattr(adapter, "_bin", lambda ctx: str(failing))
|
|
want = "exited 3: boom"
|
|
assert adapter.purge_artifacts("ses_main", ctx) == []
|
|
assert want in capsys.readouterr().err
|
|
assert f"{adapter._resolve_db(ctx)} (session_v2.id: 1 rows)" in adapter.residual_artifacts("ses_main", ctx)
|
|
|
|
|
|
def test_spawn_and_resume_never_pass_agent_and_run_standalone():
|
|
"""The shell copies of the opencode command line match the adapter."""
|
|
adapter = get_adapter("opencode")
|
|
assert adapter.spawn_spec("opencode") == "opencode --auto --standalone"
|
|
assert adapter.resume_spec("opencode", "ses_x", materialized=True) == "opencode --session ses_x --auto --standalone"
|
|
for rel in ("multi-agent-mux-create/scripts/create_session.sh",
|
|
"multi-agent-mux-resume/scripts/resume_session.sh",
|
|
"multi-agent-mux-resume/scripts/update_yaml_resumed.sh"):
|
|
text = (SKILLS / rel).read_text()
|
|
lines = [l for l in text.splitlines() if "opencode" in l and "--auto" in l]
|
|
assert lines, rel
|
|
for l in lines:
|
|
assert "--agent" not in l and "--standalone" in l, (rel, l)
|
|
|
|
|
|
def test_status_and_reconcile_read_session_v2():
|
|
for rel in ("multi-agent-mux-status/scripts/status.sh", "multi-agent-mux-monitor/scripts/reconcile.sh"):
|
|
text = (SKILLS / rel).read_text()
|
|
assert not re.search(r"FROM session\b(?!_)|table_info\(session\)", text), rel
|
|
assert "session_v2" in text, rel
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# a real opencode 2.x, in a home of its own
|
|
# ---------------------------------------------------------------------------
|
|
def _real_opencode():
|
|
path = shutil.which("opencode") or os.path.expanduser("~/.opencode/bin/opencode")
|
|
if not os.path.isfile(path):
|
|
return None
|
|
try:
|
|
out = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=20).stdout
|
|
except Exception:
|
|
return None
|
|
m = re.search(r"(\d+)\.\d+\.\d+", out)
|
|
return path if m and int(m.group(1)) >= 2 else None
|
|
|
|
|
|
REAL = _real_opencode()
|
|
|
|
|
|
@pytest.mark.skipif(REAL is None, reason="no opencode 2.x installed")
|
|
def test_real_opencode_2x_contract(tmp_path, monkeypatch):
|
|
home = tmp_path / "home"
|
|
ws = tmp_path / "ws"
|
|
for d in (home, ws, tmp_path / "tmp"):
|
|
d.mkdir()
|
|
ws_real = os.path.realpath(str(ws))
|
|
monkeypatch.setenv("HOME", str(home))
|
|
monkeypatch.setenv("TMPDIR", str(tmp_path / "tmp"))
|
|
for k in ("XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "HOME_DIR"):
|
|
monkeypatch.delenv(k, raising=False)
|
|
env = {**os.environ, "TERM": "dumb"}
|
|
|
|
def cli(*args, check=True):
|
|
res = subprocess.run([REAL, *args], capture_output=True, text=True, timeout=120, cwd=ws_real, env=env)
|
|
if check:
|
|
assert res.returncode == 0, (args, res.stdout, res.stderr)
|
|
return res
|
|
|
|
help_text = cli("--help").stdout
|
|
assert "--standalone" in help_text and "--session" in help_text
|
|
assert not re.search(r"^\s*--agent\b", help_text, re.M), "the TUI takes --agent again: revisit spawn_spec"
|
|
|
|
import json
|
|
parent = json.loads(cli("api", "--standalone", "POST", "/api/session", "-d", '{"title":"mam test"}').stdout)["data"]["id"]
|
|
child = json.loads(cli("api", "--standalone", "POST", "/api/session", "-d",
|
|
json.dumps({"title": "mam child", "parentID": parent})).stdout)["data"]["id"]
|
|
|
|
adapter = get_adapter("opencode")
|
|
ctx = DiscoveryContext(workspace=ws_real, agent_name="opencode", home_dir=str(home))
|
|
db = adapter._resolve_db(ctx)
|
|
assert db == str(home / ".local" / "share" / "opencode" / "opencode.db")
|
|
assert adapter.discover(ctx) == [parent]
|
|
assert adapter.verify_artifact(child, ctx) is True
|
|
|
|
purged = adapter.purge_artifacts(parent, ctx)
|
|
assert purged == [f"opencode session (with its child sessions): {parent}"]
|
|
assert adapter.residual_artifacts(parent, ctx) == []
|
|
assert adapter.residual_artifacts(child, ctx) == []
|
|
gone = cli("session", "delete", "--standalone", "--", parent, check=False)
|
|
assert gone.returncode == 1 and "Session not found" in gone.stdout + gone.stderr
|