test(opencode): the 2.x schema and CLI, a fake opencode, and an opt-in real-binary contract (Issue #60)

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
This commit is contained in:
GodopuandClaude Opus 5.5 committed 2026-10-18 14:58:29 +09:00
1 parent a311c23303
commit f0d1c000f9
5 files changed
+353 -147

No files matched your search

+64
View File
@@ -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] [--] <id>` 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 <id> deleted`, or
`Session not found: <id>` 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
+5 -21
View File
@@ -131,6 +131,7 @@ if _TESTS_DIR not in sys.path:
sys.path.insert(0, _TESTS_DIR) sys.path.insert(0, _TESTS_DIR)
import _mam_state_guard # noqa: E402 import _mam_state_guard # noqa: E402
import _agent_home_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") _BINDING_VARS = ("WORKSPACE_ROOT", "AGENT_SESSIONS_YAML", "MAM_JOBS_DIR", "DELEGATE_JOB_LOGS_DIR")
_RECORDED_BINDING = {} _RECORDED_BINDING = {}
@@ -971,9 +972,9 @@ elif cmd1 == "agent":
os.makedirs(opencode_dir, exist_ok=True) os.makedirs(opencode_dir, exist_ok=True)
odb = os.path.join(opencode_dir, "opencode.db") odb = os.path.join(opencode_dir, "opencode.db")
conn = sqlite3.connect(odb) 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) 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.commit()
conn.close() conn.close()
elif agent_type == "codex": elif agent_type == "codex":
@@ -1466,25 +1467,8 @@ sys.exit(0)
""") """)
grok_bin.chmod(0o755) grok_bin.chmod(0o755)
# 5. opencode mock # 5. opencode mock (the 2.x commands MAM calls, Issue #60)
opencode_bin = bin_dir / "opencode" _fake_opencode.write(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)
# 6. codex mock # 6. codex mock
codex_bin = bin_dir / "codex" codex_bin = bin_dir / "codex"
+41 -121
View File
@@ -3,6 +3,7 @@
import os, pytest import os, pytest
from lib_py.paths import resolve_home from lib_py.paths import resolve_home
from lib_py.agents.registry import get_adapter, own_key, agent_of_row 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(): def test_resolve_home_contract():
# 1. When explicit home_dir is passed # 1. When explicit home_dir is passed
@@ -132,73 +133,19 @@ def shlex_quote(s):
import shlex import shlex
return shlex.quote(s) 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 import sqlite3
os.makedirs(os.path.dirname(db_path), exist_ok=True) os.makedirs(os.path.dirname(db_path), exist_ok=True)
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
cursor = conn.cursor() 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)")
cursor.execute("PRAGMA foreign_keys = OFF") conn.execute("INSERT OR REPLACE INTO session_v2 (id, parent_id, directory, time_created) VALUES (?, ?, ?, ?)",
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") (session_id, parent_id, ws_path, int(float(epoch_sec) * 1000)))
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.commit() conn.commit()
conn.close() conn.close()
def test_purge_artifacts_composite(tmp_path): def test_purge_artifacts_composite(tmp_path, monkeypatch):
import sqlite3 import sqlite3
from lib_py.agents.base import DiscoveryContext from lib_py.agents.base import DiscoveryContext
ws = str(tmp_path / "ws") 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(g_path)
assert not os.path.exists(os.path.dirname(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') 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) opencode_ctx = DiscoveryContext(workspace=ws, agent_name='opencode', home_dir=home)
o_db = opencode_adapter.artifact_path('uuid-o', opencode_ctx) 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) _seed_opencode_session(o_db, 'uuid-o', ws, 2000.0)
conn = sqlite3.connect(o_db) conn = sqlite3.connect(o_db)
cursor = conn.cursor() conn.execute("CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT)")
cursor.execute("PRAGMA foreign_keys = OFF") conn.execute("CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT)")
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") conn.execute("CREATE TABLE part (id TEXT PRIMARY KEY, session_id TEXT)")
tables = {row[0] for row in cursor.fetchall()} conn.execute("INSERT INTO session_message VALUES ('sm1', 'uuid-o')")
if "message" not in tables: conn.execute("INSERT INTO message VALUES ('m1', 'uuid-o')")
conn.execute("CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT)") conn.execute("INSERT INTO part VALUES ('p1', 'uuid-o')")
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.commit() conn.commit()
conn.close() conn.close()
purged_o = opencode_adapter.purge_artifacts('uuid-o', opencode_ctx) 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) 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 message WHERE session_id='uuid-o'").fetchone()[0] == 0
assert conn.execute("SELECT count(*) FROM part 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() 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' assert grok.resume_spec('grok', 'u1', materialized=False) == 'grok --session-id u1 --permission-mode bypassPermissions'
opencode = get_adapter('opencode') opencode = get_adapter('opencode')
assert opencode.spawn_spec('opencode', 'u1') == 'opencode --auto --agent build' assert opencode.spawn_spec('opencode', 'u1') == 'opencode --auto --standalone'
assert opencode.spawn_spec('opencode', '') == 'opencode --auto --agent build' assert opencode.spawn_spec('opencode', '') == 'opencode --auto --standalone'
assert opencode.resume_spec('opencode', 'u1', materialized=True) == 'opencode --session u1 --auto --agent build' assert opencode.resume_spec('opencode', 'u1', materialized=True) == 'opencode --session u1 --auto --standalone'
assert opencode.resume_spec('opencode', 'u1', materialized=False) == 'opencode --auto --agent build' assert opencode.resume_spec('opencode', 'u1', materialized=False) == 'opencode --auto --standalone'
codex = get_adapter('codex') codex = get_adapter('codex')
assert codex.spawn_spec('codex', 'u1') == 'codex --dangerously-bypass-approvals-and-sandbox' 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" odb = f"{home}/.local/share/opencode/opencode.db"
os.makedirs(os.path.dirname(odb), exist_ok=True) os.makedirs(os.path.dirname(odb), exist_ok=True)
conn = sqlite3.connect(odb) 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 epoch_floor_sec = 1724920000
conn.execute("INSERT INTO session VALUES ('u-target-fresh', ?, ?)", (ws1, int((epoch_floor_sec + 100) * 1000))) ins = "INSERT INTO session_v2 (id, directory, time_created) VALUES (?, ?, ?)"
conn.execute("INSERT INTO session VALUES ('u-target-old', ?, ?)", (ws1, int((epoch_floor_sec - 100) * 1000))) conn.execute(ins, ('u-target-fresh', ws1, int((epoch_floor_sec + 100) * 1000)))
conn.execute("INSERT INTO session VALUES ('u-foreign', ?, ?)", (ws2, int((epoch_floor_sec + 200) * 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.commit()
conn.close() 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): 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 import stat
from lib_py.agents.registry import get_adapter from lib_py.agents.registry import get_adapter
from lib_py.agents.base import DiscoveryContext 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(home, exist_ok=True)
os.makedirs(ws, exist_ok=True) os.makedirs(ws, exist_ok=True)
expected_db = f"{home}/.local/share/opencode/custom_opencode.db" expected_db = f"{tmp_path}/elsewhere/opencode/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
bin_dir = tmp_path / "bin" bin_dir = tmp_path / "bin"
os.makedirs(bin_dir, exist_ok=True) os.makedirs(bin_dir, exist_ok=True)
mock_bin = bin_dir / "opencode" mock_bin = bin_dir / "opencode"
mock_bin.write_text(f"""#!/bin/sh mock_bin.write_text(f"""#!/bin/sh
if [ "$1" = "db" ] && [ "$2" = "path" ]; then if [ "$1" = "debug" ] && [ "$2" = "paths" ]; then
echo "{expected_db}" echo "home {home}"
echo "data {tmp_path}/elsewhere/opencode"
echo "db {expected_db}"
exit 0 exit 0
fi fi
exit 1 exit 1
@@ -877,6 +794,9 @@ exit 1
ctx = DiscoveryContext(workspace=ws, agent_name='opencode', home_dir=home) ctx = DiscoveryContext(workspace=ws, agent_name='opencode', home_dir=home)
assert adapter._resolve_db(ctx) == expected_db 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): def _seed_codex_session(home_dir, session_id, ws_path, epoch_sec):
import sqlite3, json, time import sqlite3, json, time
+15 -5
View File
@@ -34,6 +34,7 @@ sys.path.insert(0, str(SKILLS))
from lib_py.agents.registry import get_adapter # noqa: E402 from lib_py.agents.registry import get_adapter # noqa: E402
from lib_py.agents.base import DiscoveryContext # noqa: E402 from lib_py.agents.base import DiscoveryContext # noqa: E402
from lib_py.verify_session import workspace_key # 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", SESSION_ENV = ("CLAUDE_CODE_SESSION_ID", "ANTIGRAVITY_CONVERSATION_ID",
"HERMES_SESSION_ID", "GROK_SESSION_ID", "OPENCODE_SESSION_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): 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}" u, o = f"ses_{uuidlib.uuid4().hex}", f"ses_{uuidlib.uuid4().hex}"
db = home / ".local" / "share" / "opencode" / "opencode.db" db = home / ".local" / "share" / "opencode" / "opencode.db"
ddl = ["CREATE TABLE session (id TEXT PRIMARY KEY)", 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 message (id TEXT, session_id TEXT)",
"CREATE TABLE part (id TEXT, session_id TEXT)", "CREATE TABLE part (id TEXT, session_id TEXT)",
"CREATE TABLE event (id TEXT, aggregate_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 = [] rows = []
for s in (u, o): for s in (u, o):
rows += [("INSERT INTO session VALUES (?)", (s,)), 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 message VALUES ('m', ?)", (s,)),
("INSERT INTO part VALUES ('p', ?)", (s,)), ("INSERT INTO part VALUES ('p', ?)", (s,)),
("INSERT INTO event VALUES ('e1', ?)", (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,))] ("INSERT INTO todo VALUES ('t', ?)", (s,))]
_db(db, ddl, rows) _db(db, ddl, rows)
adapter = get_adapter("opencode") 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)) 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)", assert adapter.residual_artifacts(u, ctx) == [f"{db} (todo.session_id: 1 rows)"]
f"{db} (event_sequence.aggregate_id: 1 rows)", assert len(adapter.residual_artifacts(o, ctx)) == 8 # the other session is untouched: one entry per table
f"{db} (todo.session_id: 1 rows)"]
def test_codex_residual_reports_name_and_thread_rows(home, tmp_path, monkeypatch, conv, other): def test_codex_residual_reports_name_and_thread_rows(home, tmp_path, monkeypatch, conv, other):
+228
View File
@@ -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