diff --git a/tests/_fake_opencode.py b/tests/_fake_opencode.py new file mode 100644 index 0000000..3377f7b --- /dev/null +++ b/tests/_fake_opencode.py @@ -0,0 +1,64 @@ +"""A fake opencode 2.x CLI for tests (Issue #60): only what MAM calls. + +- `debug paths` prints the `data` and `db` lines of the real command (the db + follows XDG_DATA_HOME, else HOME/.local/share; the database is not created); +- `session delete [--standalone] [--] ` deletes the session and its child + sessions from the 2.x tables (session_v2, session_message, session_inbox, + session_pending, event, event_sequence) and leaves the tables a 1.x + migration keeps (session, message, part, todo, session_share), as the real + 2.0.32 command was measured to do; prints `Session deleted`, or + `Session not found: ` with exit 1. + +Tests never run a real opencode: a real one would migrate the test's +database. The contract against a real binary is checked by the opt-in test in +tests/test_issue60_opencode_v2.py. +""" +import os +import stat + +SCRIPT = r'''#!/usr/bin/env python3 +import os, sqlite3, sys +args = sys.argv[1:] +data = os.environ.get("XDG_DATA_HOME") or os.path.join(os.environ.get("HOME", ""), ".local", "share") +db = os.path.join(data, "opencode", "opencode.db") +if args == ["debug", "paths"]: + print("data " + os.path.dirname(db)) + print("db " + db) + sys.exit(0) +if args[:2] == ["session", "delete"]: + sid = args[-1] + conn = sqlite3.connect(db) + try: + if not conn.execute("SELECT 1 FROM session_v2 WHERE id=?", (sid,)).fetchone(): + print("Session not found: " + sid, file=sys.stderr) + sys.exit(1) + ids, todo = [], [sid] + while todo: + cur = todo.pop() + ids.append(cur) + todo += [r[0] for r in conn.execute("SELECT id FROM session_v2 WHERE parent_id=?", (cur,))] + tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} + v2 = (("session_message", "session_id"), ("session_inbox", "session_id"), + ("session_pending", "session_id"), ("event", "aggregate_id"), + ("event_sequence", "aggregate_id"), ("session_v2", "id")) + for t, col in v2: + if t in tables: + for i in ids: + conn.execute('DELETE FROM "%s" WHERE "%s"=?' % (t, col), (i,)) + conn.commit() + finally: + conn.close() + print("Session " + sid + " deleted") + sys.exit(0) +sys.exit(0) +''' + + +def write(path) -> str: + """Write the fake as an executable `path` and return it as a str.""" + path = str(path) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + f.write(SCRIPT) + os.chmod(path, os.stat(path).st_mode | stat.S_IEXEC) + return path diff --git a/tests/conftest.py b/tests/conftest.py index 0f058a5..8aba27d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -131,6 +131,7 @@ if _TESTS_DIR not in sys.path: sys.path.insert(0, _TESTS_DIR) import _mam_state_guard # noqa: E402 import _agent_home_guard # noqa: E402 +import _fake_opencode # noqa: E402 _BINDING_VARS = ("WORKSPACE_ROOT", "AGENT_SESSIONS_YAML", "MAM_JOBS_DIR", "DELEGATE_JOB_LOGS_DIR") _RECORDED_BINDING = {} @@ -971,9 +972,9 @@ elif cmd1 == "agent": os.makedirs(opencode_dir, exist_ok=True) odb = os.path.join(opencode_dir, "opencode.db") conn = sqlite3.connect(odb) - conn.execute("CREATE TABLE IF NOT EXISTS session (id TEXT PRIMARY KEY, directory TEXT, time_created INTEGER, cwd TEXT, created_at REAL)") + conn.execute("CREATE TABLE IF NOT EXISTS session_v2 (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT, time_created INTEGER)") now_ms = int(time.time() * 1000) - conn.execute("INSERT OR REPLACE INTO session (id, directory, time_created, cwd, created_at) VALUES (?, ?, ?, ?, ?)", (session_uuid, ws_abs, now_ms, ws_abs, time.time())) + conn.execute("INSERT OR REPLACE INTO session_v2 (id, directory, time_created) VALUES (?, ?, ?)", (session_uuid, ws_abs, now_ms)) conn.commit() conn.close() elif agent_type == "codex": @@ -1466,25 +1467,8 @@ sys.exit(0) """) grok_bin.chmod(0o755) - # 5. opencode mock - opencode_bin = bin_dir / "opencode" - opencode_bin.write_text("""#!/usr/bin/env python3 -import sys, os -args = sys.argv[1:] -if len(args) >= 2 and args[0] == "db" and args[1] == "path": - home = os.environ.get("HOME", "") - db_path = f"{home}/.local/share/opencode/opencode.db" - os.makedirs(os.path.dirname(db_path), exist_ok=True) - if not os.path.exists(db_path): - import sqlite3 - conn = sqlite3.connect(db_path) - conn.execute("CREATE TABLE IF NOT EXISTS session (id TEXT PRIMARY KEY, directory TEXT, time_created INTEGER)") - conn.close() - print(db_path) - sys.exit(0) -sys.exit(0) -""") - opencode_bin.chmod(0o755) + # 5. opencode mock (the 2.x commands MAM calls, Issue #60) + _fake_opencode.write(bin_dir / "opencode") # 6. codex mock codex_bin = bin_dir / "codex" diff --git a/tests/test_a4_adapter_contract.py b/tests/test_a4_adapter_contract.py index c5756b3..2affd53 100644 --- a/tests/test_a4_adapter_contract.py +++ b/tests/test_a4_adapter_contract.py @@ -3,6 +3,7 @@ import os, pytest from lib_py.paths import resolve_home from lib_py.agents.registry import get_adapter, own_key, agent_of_row +import _fake_opencode # tests/ is on sys.path (conftest) def test_resolve_home_contract(): # 1. When explicit home_dir is passed @@ -132,73 +133,19 @@ def shlex_quote(s): import shlex return shlex.quote(s) -def _seed_opencode_session(db_path, session_id, ws_path, epoch_sec): +def _seed_opencode_session(db_path, session_id, ws_path, epoch_sec, parent_id=None): + """A session row of the opencode 2.x schema (session_v2, Issue #60).""" import sqlite3 os.makedirs(os.path.dirname(db_path), exist_ok=True) conn = sqlite3.connect(db_path) - cursor = conn.cursor() - cursor.execute("PRAGMA foreign_keys = OFF") - cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") - tables = {row[0] for row in cursor.fetchall()} - if "session" not in tables: - conn.execute("CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT, time_created INTEGER, cwd TEXT, created_at REAL)") - if "project" in tables: - cursor.execute("PRAGMA table_info(project)") - p_cols = [col[1] for col in cursor.fetchall()] - p_ins = ["id"] - p_vals = ["default"] - if "worktree" in p_cols: - p_ins.append("worktree") - p_vals.append(ws_path) - if "time_created" in p_cols: - p_ins.append("time_created") - p_vals.append(0) - if "time_updated" in p_cols: - p_ins.append("time_updated") - p_vals.append(0) - conn.execute(f"INSERT OR IGNORE INTO project ({', '.join(p_ins)}) VALUES ({', '.join(['?']*len(p_vals))})", p_vals) - - cursor.execute("PRAGMA table_info(session)") - cols = [col[1] for col in cursor.fetchall()] - - insert_cols = ["id"] - vals = [session_id] - if "project_id" in cols: - insert_cols.append("project_id") - vals.append("default") - if "slug" in cols: - insert_cols.append("slug") - vals.append("default") - if "title" in cols: - insert_cols.append("title") - vals.append("default") - if "version" in cols: - insert_cols.append("version") - vals.append("1.0.0") - if "directory" in cols: - insert_cols.append("directory") - vals.append(ws_path) - if "cwd" in cols: - insert_cols.append("cwd") - vals.append(ws_path) - if "time_created" in cols: - insert_cols.append("time_created") - vals.append(int(float(epoch_sec) * 1000)) - if "time_updated" in cols: - insert_cols.append("time_updated") - vals.append(int(float(epoch_sec) * 1000)) - if "created_at" in cols: - insert_cols.append("created_at") - vals.append(float(epoch_sec)) - - placeholders = ", ".join(["?"] * len(vals)) - col_str = ", ".join(insert_cols) - conn.execute(f"INSERT OR REPLACE INTO session ({col_str}) VALUES ({placeholders})", vals) + conn.execute("CREATE TABLE IF NOT EXISTS session_v2 (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT NOT NULL, time_created INTEGER NOT NULL)") + conn.execute("INSERT OR REPLACE INTO session_v2 (id, parent_id, directory, time_created) VALUES (?, ?, ?, ?)", + (session_id, parent_id, ws_path, int(float(epoch_sec) * 1000))) conn.commit() conn.close() -def test_purge_artifacts_composite(tmp_path): +def test_purge_artifacts_composite(tmp_path, monkeypatch): import sqlite3 from lib_py.agents.base import DiscoveryContext ws = str(tmp_path / "ws") @@ -272,60 +219,31 @@ def test_purge_artifacts_composite(tmp_path): assert not os.path.exists(g_path) assert not os.path.exists(os.path.dirname(g_path)) - # 5. OpenCode (SQLite tables session, message, part) + # 5. OpenCode 2.x: `opencode session delete` removes the session (the + # fake opencode, as the real one does), and the rows a 1.x opencode left + # behind (message, part) are deleted directly (Issue #60). opencode_adapter = get_adapter('opencode') + fake = _fake_opencode.write(tmp_path / "fakebin" / "opencode") + monkeypatch.setattr(opencode_adapter, "_bin", lambda ctx: fake) opencode_ctx = DiscoveryContext(workspace=ws, agent_name='opencode', home_dir=home) o_db = opencode_adapter.artifact_path('uuid-o', opencode_ctx) + assert o_db == f"{home}/.local/share/opencode/opencode.db" _seed_opencode_session(o_db, 'uuid-o', ws, 2000.0) conn = sqlite3.connect(o_db) - cursor = conn.cursor() - cursor.execute("PRAGMA foreign_keys = OFF") - cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") - tables = {row[0] for row in cursor.fetchall()} - if "message" not in tables: - conn.execute("CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT)") - if "part" not in tables: - conn.execute("CREATE TABLE part (id TEXT PRIMARY KEY, session_id TEXT)") - - cursor.execute("PRAGMA table_info(message)") - m_cols = [c[1] for c in cursor.fetchall()] - m_ins = ["id", "session_id"] if "id" in m_cols else ["session_id"] - m_vals = ["m1", "uuid-o"] if "id" in m_cols else ["uuid-o"] - if "time_created" in m_cols: - m_ins.append("time_created") - m_vals.append(0) - if "time_updated" in m_cols: - m_ins.append("time_updated") - m_vals.append(0) - if "data" in m_cols: - m_ins.append("data") - m_vals.append("{}") - conn.execute(f"INSERT OR REPLACE INTO message ({', '.join(m_ins)}) VALUES ({', '.join(['?']*len(m_vals))})", m_vals) - - cursor.execute("PRAGMA table_info(part)") - p_cols = [c[1] for c in cursor.fetchall()] - p_ins = ["id", "session_id"] if "id" in p_cols else ["session_id"] - p_vals = ["p1", "uuid-o"] if "id" in p_cols else ["uuid-o"] - if "message_id" in p_cols: - p_ins.append("message_id") - p_vals.append("m1") - if "time_created" in p_cols: - p_ins.append("time_created") - p_vals.append(0) - if "time_updated" in p_cols: - p_ins.append("time_updated") - p_vals.append(0) - if "data" in p_cols: - p_ins.append("data") - p_vals.append("{}") - conn.execute(f"INSERT OR REPLACE INTO part ({', '.join(p_ins)}) VALUES ({', '.join(['?']*len(p_vals))})", p_vals) + conn.execute("CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT)") + conn.execute("CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT)") + conn.execute("CREATE TABLE part (id TEXT PRIMARY KEY, session_id TEXT)") + conn.execute("INSERT INTO session_message VALUES ('sm1', 'uuid-o')") + conn.execute("INSERT INTO message VALUES ('m1', 'uuid-o')") + conn.execute("INSERT INTO part VALUES ('p1', 'uuid-o')") conn.commit() conn.close() purged_o = opencode_adapter.purge_artifacts('uuid-o', opencode_ctx) - assert len(purged_o) == 1 + assert purged_o == ["opencode session (with its child sessions): uuid-o", "sqlite rows for session: uuid-o"] conn = sqlite3.connect(o_db) - assert conn.execute("SELECT count(*) FROM session WHERE id='uuid-o'").fetchone()[0] == 0 + assert conn.execute("SELECT count(*) FROM session_v2 WHERE id='uuid-o'").fetchone()[0] == 0 + assert conn.execute("SELECT count(*) FROM session_message WHERE session_id='uuid-o'").fetchone()[0] == 0 assert conn.execute("SELECT count(*) FROM message WHERE session_id='uuid-o'").fetchone()[0] == 0 assert conn.execute("SELECT count(*) FROM part WHERE session_id='uuid-o'").fetchone()[0] == 0 conn.close() @@ -353,10 +271,10 @@ def test_adapter_spawn_and_resume_specs(): assert grok.resume_spec('grok', 'u1', materialized=False) == 'grok --session-id u1 --permission-mode bypassPermissions' opencode = get_adapter('opencode') - assert opencode.spawn_spec('opencode', 'u1') == 'opencode --auto --agent build' - assert opencode.spawn_spec('opencode', '') == 'opencode --auto --agent build' - assert opencode.resume_spec('opencode', 'u1', materialized=True) == 'opencode --session u1 --auto --agent build' - assert opencode.resume_spec('opencode', 'u1', materialized=False) == 'opencode --auto --agent build' + assert opencode.spawn_spec('opencode', 'u1') == 'opencode --auto --standalone' + assert opencode.spawn_spec('opencode', '') == 'opencode --auto --standalone' + assert opencode.resume_spec('opencode', 'u1', materialized=True) == 'opencode --session u1 --auto --standalone' + assert opencode.resume_spec('opencode', 'u1', materialized=False) == 'opencode --auto --standalone' codex = get_adapter('codex') assert codex.spawn_spec('codex', 'u1') == 'codex --dangerously-bypass-approvals-and-sandbox' @@ -821,12 +739,13 @@ def test_opencode_real_schema_directory_and_time_created_ms(tmp_path): odb = f"{home}/.local/share/opencode/opencode.db" os.makedirs(os.path.dirname(odb), exist_ok=True) conn = sqlite3.connect(odb) - conn.execute("CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT NOT NULL, time_created INTEGER NOT NULL)") + conn.execute("CREATE TABLE session_v2 (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT NOT NULL, time_created INTEGER NOT NULL)") epoch_floor_sec = 1724920000 - conn.execute("INSERT INTO session VALUES ('u-target-fresh', ?, ?)", (ws1, int((epoch_floor_sec + 100) * 1000))) - conn.execute("INSERT INTO session VALUES ('u-target-old', ?, ?)", (ws1, int((epoch_floor_sec - 100) * 1000))) - conn.execute("INSERT INTO session VALUES ('u-foreign', ?, ?)", (ws2, int((epoch_floor_sec + 200) * 1000))) + ins = "INSERT INTO session_v2 (id, directory, time_created) VALUES (?, ?, ?)" + conn.execute(ins, ('u-target-fresh', ws1, int((epoch_floor_sec + 100) * 1000))) + conn.execute(ins, ('u-target-old', ws1, int((epoch_floor_sec - 100) * 1000))) + conn.execute(ins, ('u-foreign', ws2, int((epoch_floor_sec + 200) * 1000))) conn.commit() conn.close() @@ -844,7 +763,8 @@ def test_opencode_real_schema_directory_and_time_created_ms(tmp_path): def test_opencode_resolve_db_cli_path(tmp_path, monkeypatch): - """Verify _resolve_db correctly executes opencode db path and falls back to candidate paths.""" + """_resolve_db reads the `db` line of `opencode debug paths` (2.x; the 1.x + `db path` only prints the help) and falls back to the default path.""" import stat from lib_py.agents.registry import get_adapter from lib_py.agents.base import DiscoveryContext @@ -855,18 +775,15 @@ def test_opencode_resolve_db_cli_path(tmp_path, monkeypatch): os.makedirs(home, exist_ok=True) os.makedirs(ws, exist_ok=True) - expected_db = f"{home}/.local/share/opencode/custom_opencode.db" - os.makedirs(os.path.dirname(expected_db), exist_ok=True) - with open(expected_db, "w") as f: - f.write("mock db") - - # Create mock opencode binary that outputs custom DB path + expected_db = f"{tmp_path}/elsewhere/opencode/opencode.db" bin_dir = tmp_path / "bin" os.makedirs(bin_dir, exist_ok=True) mock_bin = bin_dir / "opencode" mock_bin.write_text(f"""#!/bin/sh -if [ "$1" = "db" ] && [ "$2" = "path" ]; then - echo "{expected_db}" +if [ "$1" = "debug" ] && [ "$2" = "paths" ]; then + echo "home {home}" + echo "data {tmp_path}/elsewhere/opencode" + echo "db {expected_db}" exit 0 fi exit 1 @@ -877,6 +794,9 @@ exit 1 ctx = DiscoveryContext(workspace=ws, agent_name='opencode', home_dir=home) assert adapter._resolve_db(ctx) == expected_db + monkeypatch.setattr(adapter, "_bin", lambda ctx: None) + assert adapter._resolve_db(ctx) == f"{home}/.local/share/opencode/opencode.db" + def _seed_codex_session(home_dir, session_id, ws_path, epoch_sec): import sqlite3, json, time diff --git a/tests/test_issue31_purge_residual.py b/tests/test_issue31_purge_residual.py index 898d1c1..414dc35 100644 --- a/tests/test_issue31_purge_residual.py +++ b/tests/test_issue31_purge_residual.py @@ -34,6 +34,7 @@ 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 from lib_py.verify_session import workspace_key # noqa: E402 +import _fake_opencode # noqa: E402 (tests/ is on sys.path via conftest) SESSION_ENV = ("CLAUDE_CODE_SESSION_ID", "ANTIGRAVITY_CONVERSATION_ID", "HERMES_SESSION_ID", "GROK_SESSION_ID", "OPENCODE_SESSION_ID") @@ -896,9 +897,14 @@ def _db(path: Path, ddl: list, rows: list): def test_opencode_residual_reports_event_rows(home, tmp_path, monkeypatch): + # A database migrated from 1.x (Issue #60): `opencode session delete` + # (the fake, as measured on 2.0.32) removes the 2.x rows, the purge + # deletes the 1.x session/message/part rows, and todo is reported. u, o = f"ses_{uuidlib.uuid4().hex}", f"ses_{uuidlib.uuid4().hex}" db = home / ".local" / "share" / "opencode" / "opencode.db" ddl = ["CREATE TABLE session (id TEXT PRIMARY KEY)", + "CREATE TABLE session_v2 (id TEXT PRIMARY KEY, parent_id TEXT)", + "CREATE TABLE session_message (id TEXT, session_id TEXT)", "CREATE TABLE message (id TEXT, session_id TEXT)", "CREATE TABLE part (id TEXT, session_id TEXT)", "CREATE TABLE event (id TEXT, aggregate_id TEXT)", @@ -907,6 +913,8 @@ def test_opencode_residual_reports_event_rows(home, tmp_path, monkeypatch): rows = [] for s in (u, o): rows += [("INSERT INTO session VALUES (?)", (s,)), + ("INSERT INTO session_v2 VALUES (?, NULL)", (s,)), + ("INSERT INTO session_message VALUES ('sm', ?)", (s,)), ("INSERT INTO message VALUES ('m', ?)", (s,)), ("INSERT INTO part VALUES ('p', ?)", (s,)), ("INSERT INTO event VALUES ('e1', ?)", (s,)), @@ -915,14 +923,16 @@ def test_opencode_residual_reports_event_rows(home, tmp_path, monkeypatch): ("INSERT INTO todo VALUES ('t', ?)", (s,))] _db(db, ddl, rows) adapter = get_adapter("opencode") - monkeypatch.setattr(adapter, "_resolve_db", lambda ctx: str(db)) # never run a real opencode + fake = _fake_opencode.write(tmp_path / "fakebin" / "opencode") + monkeypatch.setattr(adapter, "_bin", lambda ctx: fake) # never run a real opencode ctx = DiscoveryContext(workspace=str(tmp_path), agent_name="opencode", home_dir=str(home)) + assert f"{db} (session_v2.id: 1 rows)" in adapter.residual_artifacts(u, ctx) - adapter.purge_artifacts(u, ctx) + assert adapter.purge_artifacts(u, ctx) == [f"opencode session (with its child sessions): {u}", + f"sqlite rows for session: {u}"] - assert adapter.residual_artifacts(u, ctx) == [f"{db} (event.aggregate_id: 2 rows)", - f"{db} (event_sequence.aggregate_id: 1 rows)", - f"{db} (todo.session_id: 1 rows)"] + assert adapter.residual_artifacts(u, ctx) == [f"{db} (todo.session_id: 1 rows)"] + assert len(adapter.residual_artifacts(o, ctx)) == 8 # the other session is untouched: one entry per table def test_codex_residual_reports_name_and_thread_rows(home, tmp_path, monkeypatch, conv, other): diff --git a/tests/test_issue60_opencode_v2.py b/tests/test_issue60_opencode_v2.py new file mode 100644 index 0000000..fcd5ba0 --- /dev/null +++ b/tests/test_issue60_opencode_v2.py @@ -0,0 +1,228 @@ +"""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