diff --git a/tests/conftest.py b/tests/conftest.py index d0a1fee..14ee267 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -533,13 +533,6 @@ elif cmd1 == "agent": sys.stderr.write(json.dumps(err_payload) + "\\n") sys.exit(1) - # TUI Welcome Tokens definition to prevent TUI readiness check timeout - buffer_content = { - "claude": "Anthropic Claude Ready", - "agy": "Antigravity Ready", - "hermes": "Hermes Ready" - }.get(agent_type, "Ready") - # Look up cwd from target pane in state if not explicitly passed if not cwd and pane: for p in state.get("panes", []): @@ -550,6 +543,16 @@ elif cmd1 == "agent": if not ws and pane and ":" in pane: ws = pane.split(":")[0] + # TUI Welcome Tokens definition to prevent TUI readiness check timeout + resolved_cwd = cwd or "TMP_PATH_PLACEHOLDER" + buffer_content = { + "claude": f"Anthropic Claude Ready in {resolved_cwd}", + "agy": f"Antigravity Ready in {resolved_cwd}", + "hermes": f"Hermes Ready in {resolved_cwd}", + "grok": f"Grok Ready in {resolved_cwd}", + "opencode": f"OpenCode Ready in {resolved_cwd}" + }.get(agent_type, f"Ready in {resolved_cwd}") + agents = state.get("agents", {}) agents[name] = { "agent": agent_type, @@ -576,7 +579,9 @@ elif cmd1 == "agent": own_key_map = { "claude": "claude_session_id_own", "agy": "agy_conversation_id_own", - "hermes": "hermes_conversation_id_own" + "hermes": "hermes_conversation_id_own", + "grok": "grok_session_id_own", + "opencode": "opencode_session_id_own" } own_key = own_key_map.get(agent_type) if own_key: @@ -627,6 +632,16 @@ elif cmd1 == "agent": conn.execute("INSERT OR REPLACE INTO sessions (id, cwd) VALUES (?, ?)", (session_uuid, ws_abs)) conn.commit() conn.close() + elif agent_type == "opencode": + opencode_dir = os.path.join(home_dir, ".local", "share", "opencode") + 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)") + 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.commit() + conn.close() state["agents"] = agents save_state() @@ -946,7 +961,35 @@ sys.exit(0) """) hermes_bin.chmod(0o755) - # 4. uuidgen mock to guarantee isolated creation UUIDs + # 4. grok mock + grok_bin = bin_dir / "grok" + grok_bin.write_text("""#!/usr/bin/env python3 +import sys +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) + + # 6. uuidgen mock to guarantee isolated creation UUIDs uuidgen_bin = bin_dir / "uuidgen" uuidgen_bin.write_text("""#!/usr/bin/env python3 import uuid diff --git a/tests/test_a4_adapter_contract.py b/tests/test_a4_adapter_contract.py index 5823a71..9a6cd26 100644 --- a/tests/test_a4_adapter_contract.py +++ b/tests/test_a4_adapter_contract.py @@ -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 + + + + + diff --git a/tests/test_c1_tui_readiness.py b/tests/test_c1_tui_readiness.py index 5a29dda..4a3b46a 100644 --- a/tests/test_c1_tui_readiness.py +++ b/tests/test_c1_tui_readiness.py @@ -488,7 +488,7 @@ def test_c10_create_session_timeout_dead_pid_kills_session(mam_sandbox, mock_her def test_adapter_strong_weak_partition(): """N-D: Verify strong_ready_tokens and weak_ready_tokens partition ready_tokens for all adapters.""" from lib_py.agents.registry import get_adapter - for agent in ('claude', 'agy', 'hermes', 'grok'): + for agent in ('claude', 'agy', 'hermes', 'grok', 'opencode'): adapter = get_adapter(agent) assert adapter is not None ready_set = set(t for t in adapter.ready_tokens.split('|') if t) @@ -525,6 +525,8 @@ echo "5: $(check_sks 'test-worker-hermes-02')" echo "6: $(check_sks 'workspace-creator-agy-05')" echo "7: $(check_sks 'claude')" echo "9: $(check_sks 'grok-02')" +echo "10: $(check_sks 'creator-opencode-01')" +echo "11: $(check_sks 'opencode')" """ res = _run_lib_helpers(script) assert res.returncode == 0, res.stderr + res.stdout @@ -534,6 +536,8 @@ echo "9: $(check_sks 'grok-02')" assert "6: RESOLVED_AGENT=agy" in res.stdout assert "7: RESOLVED_AGENT=claude" in res.stdout assert "9: RESOLVED_AGENT=grok" in res.stdout + assert "10: RESOLVED_AGENT=opencode" in res.stdout + assert "11: RESOLVED_AGENT=opencode" in res.stdout def test_f2_upsell_modal_defense_with_real_facts_and_banner(): diff --git a/tests/test_tier1_unit.py b/tests/test_tier1_unit.py index aacf082..e251b4c 100644 --- a/tests/test_tier1_unit.py +++ b/tests/test_tier1_unit.py @@ -960,18 +960,18 @@ def test_grok_shell_and_scripts_integration(mam_sandbox): lib_path = mam_sandbox / "skills" / "lib.sh" lib_content = lib_path.read_text() assert '*-creator-grok|*-planner-grok|*-reviewer-grok) kind="grok"' in lib_content - assert "'claude', 'agy', 'hermes', 'grok'" in lib_content + assert "'claude', 'agy', 'hermes', 'grok', 'opencode'" in lib_content assert '[[ "$sess" =~ "grok" ]]' in lib_content # 2. create_session.sh validation create_path = mam_sandbox / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" create_content = create_path.read_text() - assert 'claude|agy|hermes|grok)' in create_content + assert 'claude|agy|hermes|grok|opencode)' in create_content # 3. stop_session.sh validation & state capture stop_path = mam_sandbox / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh" stop_content = stop_path.read_text() - assert 'claude|agy|hermes|grok)' in stop_content + assert 'claude|agy|hermes|grok|opencode)' in stop_content assert "target['grok_session_id_own'] = captured" in stop_content # 4. workspace_uuid OWN_KEY @@ -979,6 +979,71 @@ def test_grok_shell_and_scripts_integration(mam_sandbox): assert OWN_KEY.get('grok') == 'grok_session_id_own' +def test_opencode_shell_and_scripts_integration(mam_sandbox): + """Verify lib.sh, create_session.sh, stop_session.sh, and workspace_uuid contain opencode.""" + import json, subprocess + # 1. lib.sh kind mapping & binaries + lib_path = mam_sandbox / "skills" / "lib.sh" + lib_content = lib_path.read_text() + assert '*-creator-opencode|*-planner-opencode|*-reviewer-opencode) kind="opencode"' in lib_content + assert "'claude', 'agy', 'hermes', 'grok', 'opencode'" in lib_content + + # 2. create_session.sh validation & OPENCODE_PERMISSION behavior (unset & preset) + create_path = mam_sandbox / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" + create_content = create_path.read_text() + assert 'claude|agy|hermes|grok|opencode)' in create_content + # Test unset path + env_unset = {k: v for k, v in os.environ.items() if k != "OPENCODE_PERMISSION"} + res_c_unset = subprocess.run( + ["bash", "-c", 'if [ -z "${OPENCODE_PERMISSION:-}" ]; then export OPENCODE_PERMISSION=\'{"*":"allow"}\'; fi; echo "$OPENCODE_PERMISSION"'], + env=env_unset, + capture_output=True, text=True, check=True + ) + assert json.loads(res_c_unset.stdout.strip()) == {"*": "allow"} + # Test preset path (user-supplied override preserved intact) + res_c_preset = subprocess.run( + ["bash", "-c", 'if [ -z "${OPENCODE_PERMISSION:-}" ]; then export OPENCODE_PERMISSION=\'{"*":"allow"}\'; fi; echo "$OPENCODE_PERMISSION"'], + env={**os.environ, "OPENCODE_PERMISSION": '{"*":"deny"}'}, + capture_output=True, text=True, check=True + ) + assert json.loads(res_c_preset.stdout.strip()) == {"*": "deny"} + + # 3. resume_session.sh OPENCODE_PERMISSION behavior (unset & preset) + resume_path = mam_sandbox / "skills" / "multi-agent-mux-resume" / "scripts" / "resume_session.sh" + resume_content = resume_path.read_text() + assert 'export OPENCODE_PERMISSION=\'{"*":"allow"}\'' in resume_content + # Test unset path + res_r_unset = subprocess.run( + ["bash", "-c", 'if [ -z "${OPENCODE_PERMISSION:-}" ]; then export OPENCODE_PERMISSION=\'{"*":"allow"}\'; fi; echo "$OPENCODE_PERMISSION"'], + env=env_unset, + capture_output=True, text=True, check=True + ) + assert json.loads(res_r_unset.stdout.strip()) == {"*": "allow"} + # Test preset path (user-supplied override preserved intact) + res_r_preset = subprocess.run( + ["bash", "-c", 'if [ -z "${OPENCODE_PERMISSION:-}" ]; then export OPENCODE_PERMISSION=\'{"*":"allow"}\'; fi; echo "$OPENCODE_PERMISSION"'], + env={**os.environ, "OPENCODE_PERMISSION": '{"*":"deny"}'}, + capture_output=True, text=True, check=True + ) + assert json.loads(res_r_preset.stdout.strip()) == {"*": "deny"} + + # 4. stop_session.sh validation & state capture + stop_path = mam_sandbox / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh" + stop_content = stop_path.read_text() + assert 'claude|agy|hermes|grok|opencode)' in stop_content + assert "target['opencode_session_id_own'] = captured" in stop_content + + # 5. workspace_uuid OWN_KEY + from lib_py.workspace_uuid import OWN_KEY + assert OWN_KEY.get('opencode') == 'opencode_session_id_own' + + # 6. reconcile.sh RECON_SRC imports shutil and get_adapter + recon_path = mam_sandbox / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh" + recon_content = recon_path.read_text() + assert 'import os, json, glob, subprocess, time, sqlite3, re, shutil' in recon_content + assert 'op_adapter = get_adapter(\'opencode\')' in recon_content + + diff --git a/tests/test_tier2_component.py b/tests/test_tier2_component.py index 1772632..8e06e9e 100644 --- a/tests/test_tier2_component.py +++ b/tests/test_tier2_component.py @@ -1225,3 +1225,54 @@ def test_comp_reconcile_drift_b_populates_workspace_and_server(mam_sandbox, mock assert s["herdr_workspace"] != "" assert s["herdr_workspace"] != "-" + +def test_comp_opencode_create_and_reconcile_and_stop(mam_sandbox, mock_herdr, mock_agents): + """Verify create_session -> reconcile drift-C materialization -> stop flow for opencode.""" + create_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" + res = subprocess.run([ + "bash", str(create_script), + "--workspace", str(mam_sandbox), + "--agent", "opencode", + "--role", "creator" + ], capture_output=True, text=True, cwd=str(mam_sandbox)) + assert res.returncode == 0, res.stderr + + yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml" + import yaml + with open(yaml_path) as f: + data = yaml.safe_load(f) + sessions = data.get("herdr_sessions", []) + assert len(sessions) == 1 + s = sessions[0] + session_name = s["name"] + assert s["status"] == "running" + assert s["opencode_session_id_own"] is None + assert s["session_id_source"] == "pending-discovery" + + # Run reconcile.sh to trigger drift-C materialization + reconcile_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh" + res_rec = subprocess.run(["bash", str(reconcile_script), "--once", "--emit-diff"], capture_output=True, text=True, cwd=str(mam_sandbox), env={**os.environ, "WORKSPACE_ROOT": str(mam_sandbox)}) + assert res_rec.returncode == 0, res_rec.stderr + + with open(yaml_path) as f: + data_after = yaml.safe_load(f) + s_after = [x for x in data_after.get("herdr_sessions", []) if x.get("name") == session_name][0] + assert s_after.get("opencode_session_id_own") is not None + assert s_after.get("last_visible_status") in ("pinned", "resume_verified") + + # Stop session + stop_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh" + res_stop = subprocess.run([ + "bash", str(stop_script), + "--session", session_name, + "--agent", "opencode" + ], capture_output=True, text=True, cwd=str(mam_sandbox)) + assert res_stop.returncode == 0, res_stop.stderr + + with open(yaml_path) as f: + data_stopped = yaml.safe_load(f) + s_stopped = [x for x in data_stopped.get("herdr_sessions", []) if x.get("name") == session_name][0] + assert s_stopped["status"] == "stopped" + assert s_stopped["resumable"] is True + +