test(opencode): add adapter contract, TUI readiness, and lifecycle integration tests

This commit is contained in:
2026-08-29 14:59:04 +09:00
parent 94af7f6b8a
commit de2c0e6824
5 changed files with 495 additions and 15 deletions
+319 -2
View File
@@ -31,6 +31,7 @@ def test_agent_adapter_registry():
'agy': 'agy_conversation_id_own',
'hermes': 'hermes_conversation_id_own',
'grok': 'grok_session_id_own',
'opencode': 'opencode_session_id_own',
}
for agent, expected in EXPECTED_OWN_KEYS.items():
adapter = get_adapter(agent)
@@ -73,6 +74,7 @@ def test_adapter_required_properties():
'agy': ('Antigravity', 'Exit', 'antigravity-cli', ('conversation_id', 'conversation_db', 'conversation_brain_dir')),
'hermes': ('Hermes|Welcome to Hermes Agent|NOUS HERMES', '/exit', 'hermes-agent', ('session_id',)),
'grok': ('Grok|xAI|Assistant||>>>', '/exit', 'grok-build', ('session_id', 'session_jsonl')),
'opencode': ('OpenCode|Chat', '/exit', 'opencode-cli', ('session_id',)),
}
for agent, (toks, exitk, delk, cache_f) in expected.items():
adapter = get_adapter(agent)
@@ -93,6 +95,7 @@ def test_adapter_modal_tokens_contract():
'agy': None,
'hermes': None,
'grok': None,
'opencode': None,
}
for agent, expected in expected_modal.items():
adapter = get_adapter(agent)
@@ -106,7 +109,7 @@ def test_facts_bridge_eval_contract():
env = os.environ.copy()
skills_dir = str(Path(__file__).resolve().parent.parent / ".agents" / "skills")
env["PYTHONPATH"] = f"{skills_dir}:{env.get('PYTHONPATH', '')}"
for agent in ('claude', 'agy', 'hermes', 'grok'):
for agent in ('claude', 'agy', 'hermes', 'grok', 'opencode'):
res = subprocess.run([sys.executable, "-m", "lib_py.agents", "facts", agent], capture_output=True, text=True, env=env)
assert res.returncode == 0
facts_output = res.stdout
@@ -126,6 +129,72 @@ def shlex_quote(s):
import shlex
return shlex.quote(s)
def _seed_opencode_session(db_path, session_id, ws_path, epoch_sec):
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.commit()
conn.close()
def test_purge_artifacts_composite(tmp_path):
import sqlite3
from lib_py.agents.base import DiscoveryContext
@@ -200,6 +269,64 @@ 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)
opencode_adapter = get_adapter('opencode')
opencode_ctx = DiscoveryContext(workspace=ws, agent_name='opencode', home_dir=home)
o_db = opencode_adapter.artifact_path('uuid-o', opencode_ctx)
_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.commit()
conn.close()
purged_o = opencode_adapter.purge_artifacts('uuid-o', opencode_ctx)
assert len(purged_o) == 1
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 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()
def test_adapter_spawn_and_resume_specs():
claude = get_adapter('claude')
assert claude.spawn_spec('claude', 'u1') == 'claude --dangerously-skip-permissions --session-id u1'
@@ -222,6 +349,12 @@ def test_adapter_spawn_and_resume_specs():
assert grok.resume_spec('grok', 'u1', materialized=True) == 'grok --resume u1 --permission-mode bypassPermissions'
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'
def test_adapter_auth_ok(tmp_path, monkeypatch):
monkeypatch.setenv("HOME_DIR", str(tmp_path))
# Claude auth runner
@@ -251,6 +384,17 @@ def test_adapter_auth_ok(tmp_path, monkeypatch):
# Hermes always True
assert get_adapter('hermes').auth_ok() is True
# OpenCode auth check (provider env var or auth.json file)
opencode = get_adapter('opencode')
assert opencode.auth_ok() is False
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
assert opencode.auth_ok() is True
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
opencode_auth = tmp_path / ".local" / "share" / "opencode" / "auth.json"
opencode_auth.parent.mkdir(parents=True, exist_ok=True)
opencode_auth.write_text("{}")
assert opencode.auth_ok() is True
def test_adapter_discover(tmp_path):
import sqlite3
from lib_py.agents.base import DiscoveryContext
@@ -306,6 +450,13 @@ def test_adapter_discover(tmp_path):
f.write('{"session_id": "u-g1", "cwd": "' + ws + '"}\n')
assert grok.discover(ctx_g) == ['u-g1']
# 5. OpenCode
opencode = get_adapter('opencode')
ctx_o = DiscoveryContext(workspace=ws, agent_name='opencode', home_dir=home)
o_db = opencode.artifact_path('u-o1', ctx_o)
_seed_opencode_session(o_db, 'u-o1', ws, 2000.0)
assert opencode.discover(ctx_o) == ['u-o1']
def test_cli_bridge_subcommands_and_quote_safety():
import subprocess, sys
from pathlib import Path
@@ -324,7 +475,7 @@ def test_cli_bridge_subcommands_and_quote_safety():
assert res.stdout.strip() == "/bin/claude --dangerously-skip-permissions --session-id uuid-test"
# 3. exit-key
for agent, expected_key in [('claude', '/exit'), ('agy', 'Exit'), ('hermes', '/exit'), ('grok', '/exit')]:
for agent, expected_key in [('claude', '/exit'), ('agy', 'Exit'), ('hermes', '/exit'), ('grok', '/exit'), ('opencode', '/exit')]:
res = subprocess.run([sys.executable, "-m", "lib_py.agents", "exit-key", agent], capture_output=True, text=True, env=env)
assert res.returncode == 0
assert res.stdout.strip() == expected_key
@@ -336,6 +487,7 @@ def test_delegate_agent_resolution_and_fallback():
'agy': 'antigravity-cli',
'hermes': 'hermes-agent',
'grok': 'grok-build',
'opencode': 'opencode-cli',
}
# 1. Adapter property
for agent, expected_key in expected_map.items():
@@ -354,6 +506,7 @@ def test_delegate_agent_resolution_and_fallback():
hermes) delegate_agent="hermes-agent" ;;
agy) delegate_agent="antigravity-cli" ;;
grok) delegate_agent="grok-build" ;;
opencode) delegate_agent="opencode-cli" ;;
*) echo "ERROR: cannot resolve delegate agent key for '$AGENT'" >&2; exit 2 ;;
esac
fi
@@ -523,3 +676,167 @@ def test_hermes_reconcile_full_block_integration(tmp_path):
assert valid_candidates == ['u-target']
def test_opencode_input_region_properties():
adapter = get_adapter('opencode')
assert adapter.input_prompt == ''
assert adapter.input_placeholder == ''
assert adapter.input_rule_pattern == '{10,}'
def test_opencode_verify_artifact_created_at_epoch_filtering(tmp_path):
import sqlite3
from lib_py.agents.base import DiscoveryContext
ws = str(tmp_path / "ws")
home = str(tmp_path / "home")
adapter = get_adapter('opencode')
ctx_old = DiscoveryContext(workspace=ws, agent_name='opencode', home_dir=home, epoch=1500)
odb = adapter.artifact_path('u-old', ctx_old)
_seed_opencode_session(odb, 'u-old', ws, 1000.0)
_seed_opencode_session(odb, 'u-new', ws, 2000.0)
assert adapter.verify_artifact('u-old', ctx_old) is False
assert adapter.verify_artifact('u-new', ctx_old) is True
def test_opencode_reconcile_dual_direction_epoch_guards(tmp_path):
import sqlite3
from lib_py.agents.registry import get_adapter
from lib_py.agents.base import DiscoveryContext
from lib_py.verify_session import verify_session_uuid
ws = str(tmp_path / "ws")
home = str(tmp_path / "home")
os.makedirs(ws, exist_ok=True)
os.makedirs(home, exist_ok=True)
adapter = get_adapter('opencode')
ctx = DiscoveryContext(workspace=ws, agent_name='opencode', home_dir=home)
odb = adapter.artifact_path('u-hist', ctx)
_seed_opencode_session(odb, 'u-hist', ws, 1000.0)
_seed_opencode_session(odb, 'u-fresh1', ws, 2000.0)
epoch = 1500
s_row = {'name': 'sess1', 'pane': {'cwd': ws}, 'herdr_session_epoch': epoch}
valid = [u for u in adapter.discover(DiscoveryContext(workspace=ws, agent_name='opencode', home_dir=home, epoch=epoch, row=s_row)) if verify_session_uuid(ws, 'opencode', u, s_row, home_dir=home, mode="discover")]
assert valid == ['u-fresh1']
assert len(valid) == 1
_seed_opencode_session(odb, 'u-fresh2', ws, 2100.0)
valid2 = [u for u in adapter.discover(DiscoveryContext(workspace=ws, agent_name='opencode', home_dir=home, epoch=epoch, row=s_row)) if verify_session_uuid(ws, 'opencode', u, s_row, home_dir=home, mode="discover")]
assert len(valid2) == 2
def test_opencode_reconcile_full_block_integration(tmp_path):
"""Verify opencode drift-C block behavior with sibling_claimed exclusion."""
import sqlite3
from lib_py.agents.registry import get_adapter
from lib_py.agents.base import DiscoveryContext
from lib_py.verify_session import verify_session_uuid
ws = str(tmp_path / "ws")
home = str(tmp_path / "home")
os.makedirs(ws, exist_ok=True)
os.makedirs(home, exist_ok=True)
adapter = get_adapter('opencode')
ctx = DiscoveryContext(workspace=ws, agent_name='opencode', home_dir=home)
odb = adapter.artifact_path('u-target', ctx)
_seed_opencode_session(odb, 'u-claimed', ws, 2000.0)
_seed_opencode_session(odb, 'u-target', ws, 2001.0)
epoch = 1900
s_eval = {
'name': 'opencode-target-01',
'pane': {'cwd': ws},
'herdr_session_epoch': epoch,
'_sibling_claimed_uuids': ['u-claimed']
}
candidates = adapter.discover(DiscoveryContext(workspace=ws, agent_name='opencode', home_dir=home, epoch=epoch, row=s_eval))
valid_candidates = []
for uuid in candidates:
if uuid in s_eval['_sibling_claimed_uuids']:
continue
if verify_session_uuid(ws, 'opencode', uuid, s_eval, home_dir=home, mode="discover"):
valid_candidates.append(uuid)
assert valid_candidates == ['u-target']
def test_opencode_real_schema_directory_and_time_created_ms(tmp_path):
"""Verify opencode adapter behavior with the real live schema (directory, time_created in ms)."""
import sqlite3
from lib_py.agents.registry import get_adapter
from lib_py.agents.base import DiscoveryContext
ws1 = str(tmp_path / "ws1")
ws2 = str(tmp_path / "ws2")
home = str(tmp_path / "home")
os.makedirs(ws1, exist_ok=True)
os.makedirs(ws2, exist_ok=True)
os.makedirs(home, exist_ok=True)
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)")
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)))
conn.commit()
conn.close()
adapter = get_adapter('opencode')
ctx_ws1 = DiscoveryContext(workspace=ws1, agent_name='opencode', home_dir=home, epoch=epoch_floor_sec)
# verify_artifact assertions
assert adapter.verify_artifact('u-target-fresh', ctx_ws1) is True
assert adapter.verify_artifact('u-target-old', ctx_ws1) is False # Stale epoch rejected
assert adapter.verify_artifact('u-foreign', ctx_ws1) is False # Foreign workspace rejected
# discover assertions
discovered = adapter.discover(ctx_ws1)
assert discovered == ['u-target-fresh']
def test_opencode_resolve_db_cli_path(tmp_path, monkeypatch):
"""Verify _resolve_db correctly executes opencode db path and falls back to candidate paths."""
import stat
from lib_py.agents.registry import get_adapter
from lib_py.agents.base import DiscoveryContext
adapter = get_adapter('opencode')
home = str(tmp_path / "home")
ws = str(tmp_path / "ws")
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
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}"
exit 0
fi
exit 1
""")
mock_bin.chmod(mock_bin.stat().st_mode | stat.S_IEXEC)
monkeypatch.setenv("PATH", f"{bin_dir}:{os.environ.get('PATH', '')}")
ctx = DiscoveryContext(workspace=ws, agent_name='opencode', home_dir=home)
assert adapter._resolve_db(ctx) == expected_db