feat(a4,c3b): complete A-4 Phase 2 agent knowledge migration & Option B isolation removal
- Option B / C-3b: Deprecate isolation.root and remove its 4 legacy consumers in lib.sh, workspace_uuid.py, verify_session.py, and stop_session.sh. - M2~M7 Migration: Implement artifact_path, verify_artifact, purge_artifacts, spawn_spec, resume_spec, auth_ok, discover, ready_tokens, exit_key, and delegate_agent_key across BaseAgentAdapter and all 4 adapters (Claude, Agy, Hermes, Cline). - Migrate shell duplications in lib.sh, create_session.sh, resume_session.sh, stop_session.sh, and reconcile.sh to python -m lib_py.agents facts. - Hardening & Corrections: Fix Cline resume_spec flag to '-i --id', apply shlex.quote across all facts variables with MAM_ prefix, add safe fallback in wait_for_tui_ready. - Add comprehensive contract tests in tests/test_a4_adapter_contract.py and sync IMPROVEMENTS.md / LOG.md. - Verified: 100% PASS across unit, component, contract, and integration tests.
This commit is contained in:
@@ -45,3 +45,216 @@ def test_agent_of_row_priority():
|
||||
# Priority 3: pane.cmd exact match
|
||||
row3 = {'pane': {'cmd': 'cline'}}
|
||||
assert agent_of_row(row3) == 'cline'
|
||||
|
||||
def test_adapter_required_properties():
|
||||
from lib_py.agents.base import BaseAgentAdapter
|
||||
base = BaseAgentAdapter()
|
||||
for prop in ('name', 'own_key', 'ready_tokens', 'exit_key', 'delegate_agent_key', 'identity_cache_fields'):
|
||||
with pytest.raises(NotImplementedError):
|
||||
getattr(base, prop)
|
||||
|
||||
expected = {
|
||||
'claude': ('Anthropic|Assistant|Chat|Welcome', '/exit', 'claude-code', ('session_id', 'session_jsonl', 'session_size_bytes', 'session_lines')),
|
||||
'agy': ('Antigravity', 'Exit', 'antigravity-cli', ('conversation_id', 'conversation_db', 'conversation_brain_dir')),
|
||||
'hermes': ('Hermes', '/exit', 'hermes-agent', ('session_id',)),
|
||||
'cline': ('Cline|history|Chat|What can I do|slash commands', '/exit', 'cline-agent', ('session_id',)),
|
||||
}
|
||||
for agent, (toks, exitk, delk, cache_f) in expected.items():
|
||||
adapter = get_adapter(agent)
|
||||
assert adapter is not None
|
||||
assert adapter.ready_tokens == toks
|
||||
assert adapter.exit_key == exitk
|
||||
assert adapter.delegate_agent_key == delk
|
||||
assert adapter.identity_cache_fields == cache_f
|
||||
|
||||
def test_facts_bridge_eval_contract():
|
||||
import subprocess, sys
|
||||
from pathlib import Path
|
||||
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', 'cline'):
|
||||
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
|
||||
|
||||
# Verify eval in bash with set -euo pipefail
|
||||
bash_cmd = f"""
|
||||
set -euo pipefail
|
||||
eval {shlex_quote(facts_output)}
|
||||
echo "AGENT=$MAM_AGENT_NAME|OWN=$MAM_OWN_KEY|TOK=$MAM_READY_TOKENS|EXIT=$MAM_EXIT_KEY|DEL=$MAM_DELEGATE_AGENT_KEY|PH=$MAM_INPUT_PLACEHOLDER"
|
||||
"""
|
||||
res_bash = subprocess.run(["bash", "-c", bash_cmd], capture_output=True, text=True)
|
||||
assert res_bash.returncode == 0, f"Bash eval failed for {agent}:\nStdout: {res_bash.stdout}\nStderr: {res_bash.stderr}"
|
||||
if agent == 'cline':
|
||||
assert "PH=Ask anything..." in res_bash.stdout
|
||||
|
||||
def shlex_quote(s):
|
||||
import shlex
|
||||
return shlex.quote(s)
|
||||
|
||||
def test_purge_artifacts_composite(tmp_path):
|
||||
import sqlite3
|
||||
from lib_py.agents.base import DiscoveryContext
|
||||
ws = str(tmp_path / "ws")
|
||||
home = str(tmp_path / "home")
|
||||
os.makedirs(ws, exist_ok=True)
|
||||
os.makedirs(home, exist_ok=True)
|
||||
|
||||
# 1. Claude
|
||||
claude_adapter = get_adapter('claude')
|
||||
claude_ctx = DiscoveryContext(workspace=ws, agent_name='claude', home_dir=home)
|
||||
c_path = claude_adapter.artifact_path('uuid-c', claude_ctx)
|
||||
os.makedirs(os.path.dirname(c_path), exist_ok=True)
|
||||
with open(c_path, 'w') as f:
|
||||
f.write('{"sessionId": "uuid-c"}')
|
||||
assert os.path.exists(c_path)
|
||||
purged_c = claude_adapter.purge_artifacts('uuid-c', claude_ctx)
|
||||
assert len(purged_c) == 1
|
||||
assert not os.path.exists(c_path)
|
||||
|
||||
# 2. Agy (both DB file and brain dir)
|
||||
agy_adapter = get_adapter('agy')
|
||||
agy_ctx = DiscoveryContext(workspace=ws, agent_name='agy', home_dir=home)
|
||||
agy_db = agy_adapter.artifact_path('uuid-a', agy_ctx)
|
||||
os.makedirs(os.path.dirname(agy_db), exist_ok=True)
|
||||
with open(agy_db, 'w') as f:
|
||||
f.write('mock db')
|
||||
agy_brain = f"{home}/.gemini/antigravity-cli/brain/uuid-a"
|
||||
os.makedirs(agy_brain, exist_ok=True)
|
||||
with open(f"{agy_brain}/note.txt", 'w') as f:
|
||||
f.write('brain note')
|
||||
purged_a = agy_adapter.purge_artifacts('uuid-a', agy_ctx)
|
||||
assert len(purged_a) == 2
|
||||
assert not os.path.exists(agy_db)
|
||||
assert not os.path.exists(agy_brain)
|
||||
|
||||
# 3. Hermes (JSON file and SQLite rows)
|
||||
hermes_adapter = get_adapter('hermes')
|
||||
hermes_ctx = DiscoveryContext(workspace=ws, agent_name='hermes', home_dir=home)
|
||||
h_json = hermes_adapter.artifact_path('uuid-h', hermes_ctx)
|
||||
os.makedirs(os.path.dirname(h_json), exist_ok=True)
|
||||
with open(h_json, 'w') as f:
|
||||
f.write('{}')
|
||||
h_db = f"{home}/.hermes/state.db"
|
||||
os.makedirs(os.path.dirname(h_db), exist_ok=True)
|
||||
conn = sqlite3.connect(h_db)
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS sessions (id TEXT, cwd TEXT)")
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS messages (session_id TEXT, msg TEXT)")
|
||||
conn.execute("INSERT INTO sessions VALUES (?, ?)", ('uuid-h', ws))
|
||||
conn.execute("INSERT INTO messages VALUES (?, ?)", ('uuid-h', 'hello'))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
purged_h = hermes_adapter.purge_artifacts('uuid-h', hermes_ctx)
|
||||
assert len(purged_h) == 2
|
||||
assert not os.path.exists(h_json)
|
||||
conn = sqlite3.connect(h_db)
|
||||
assert conn.execute("SELECT count(*) FROM sessions WHERE id='uuid-h'").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT count(*) FROM messages WHERE session_id='uuid-h'").fetchone()[0] == 0
|
||||
conn.close()
|
||||
|
||||
# 4. Cline (sessions dir)
|
||||
cline_adapter = get_adapter('cline')
|
||||
cline_ctx = DiscoveryContext(workspace=ws, agent_name='cline', home_dir=home)
|
||||
cline_dir = f"{home}/.cline/data/sessions/uuid-cl"
|
||||
os.makedirs(cline_dir, exist_ok=True)
|
||||
with open(f"{cline_dir}/uuid-cl.json", 'w') as f:
|
||||
f.write('{"session_id": "uuid-cl"}')
|
||||
purged_cl = cline_adapter.purge_artifacts('uuid-cl', cline_ctx)
|
||||
assert len(purged_cl) == 1
|
||||
assert not os.path.exists(cline_dir)
|
||||
|
||||
def test_adapter_spawn_and_resume_specs():
|
||||
claude = get_adapter('claude')
|
||||
assert claude.spawn_spec('claude', 'u1') == 'claude --dangerously-skip-permissions --session-id u1'
|
||||
assert claude.spawn_spec('claude', '', use_wrapper=True) == 'claude --dangerously-skip-permissions'
|
||||
assert claude.resume_spec('claude', 'u1', materialized=True) == 'claude --dangerously-skip-permissions -r u1'
|
||||
assert claude.resume_spec('claude', 'u1', materialized=False) == 'claude --dangerously-skip-permissions --session-id u1'
|
||||
|
||||
agy = get_adapter('agy')
|
||||
assert agy.spawn_spec('agy', 'u1') == 'agy --dangerously-skip-permissions'
|
||||
assert agy.resume_spec('agy', 'u1', materialized=True) == 'agy --dangerously-skip-permissions --conversation u1'
|
||||
|
||||
hermes = get_adapter('hermes')
|
||||
assert hermes.spawn_spec('hermes', 'u1') == 'hermes'
|
||||
assert hermes.resume_spec('hermes', 'u1', materialized=True) == 'hermes --resume u1'
|
||||
|
||||
cline = get_adapter('cline')
|
||||
assert cline.spawn_spec('cline', 'u1') == 'cline -i'
|
||||
assert cline.resume_spec('cline', 'u1', materialized=True) == 'cline -i --id u1'
|
||||
assert cline.resume_spec('cline', 'u1', materialized=False) == 'cline -i'
|
||||
|
||||
def test_adapter_auth_ok(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME_DIR", str(tmp_path))
|
||||
# Claude auth runner
|
||||
claude = get_adapter('claude')
|
||||
assert claude.auth_ok(run_cmd=lambda cmd: (0, '{"loggedIn": true}', '')) is True
|
||||
assert claude.auth_ok(run_cmd=lambda cmd: (1, '{"loggedIn": false}', 'error')) is False
|
||||
|
||||
# Agy auth file check
|
||||
agy = get_adapter('agy')
|
||||
assert agy.auth_ok() is False
|
||||
oauth_file = tmp_path / ".gemini" / "oauth_creds.json"
|
||||
oauth_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
oauth_file.write_text("{}")
|
||||
assert agy.auth_ok() is True
|
||||
|
||||
# Hermes & Cline always True
|
||||
assert get_adapter('hermes').auth_ok() is True
|
||||
assert get_adapter('cline').auth_ok() is True
|
||||
|
||||
def test_adapter_discover(tmp_path):
|
||||
import sqlite3
|
||||
from lib_py.agents.base import DiscoveryContext
|
||||
ws = str(tmp_path / "ws")
|
||||
home = str(tmp_path / "home")
|
||||
os.makedirs(ws, exist_ok=True)
|
||||
os.makedirs(home, exist_ok=True)
|
||||
|
||||
# 1. Claude
|
||||
claude = get_adapter('claude')
|
||||
ctx_c = DiscoveryContext(workspace=ws, agent_name='claude', home_dir=home)
|
||||
c_proj = f"{ctx_c.claude_dir}/{ctx_c.ws_key}"
|
||||
os.makedirs(c_proj, exist_ok=True)
|
||||
with open(f"{c_proj}/u-c1.jsonl", 'w') as f:
|
||||
f.write('{"sessionId": "u-c1"}\n')
|
||||
assert claude.discover(ctx_c) == ['u-c1']
|
||||
|
||||
# 2. Agy
|
||||
agy = get_adapter('agy')
|
||||
ctx_a = DiscoveryContext(workspace=ws, agent_name='agy', home_dir=home)
|
||||
a_db = f"{home}/.gemini/antigravity-cli/conversations/u-a1.db"
|
||||
os.makedirs(os.path.dirname(a_db), exist_ok=True)
|
||||
conn = sqlite3.connect(a_db)
|
||||
conn.execute("CREATE TABLE steps (id INT)")
|
||||
conn.execute("INSERT INTO steps VALUES (1)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
lc = f"{home}/.gemini/antigravity-cli/cache/last_conversations.json"
|
||||
os.makedirs(os.path.dirname(lc), exist_ok=True)
|
||||
with open(lc, 'w') as f:
|
||||
import json
|
||||
json.dump({ws: 'u-a1'}, f)
|
||||
assert agy.discover(ctx_a) == ['u-a1']
|
||||
|
||||
# 3. Hermes
|
||||
hermes = get_adapter('hermes')
|
||||
ctx_h = DiscoveryContext(workspace=ws, agent_name='hermes', home_dir=home)
|
||||
h_db = f"{home}/.hermes/state.db"
|
||||
os.makedirs(os.path.dirname(h_db), exist_ok=True)
|
||||
conn = sqlite3.connect(h_db)
|
||||
conn.execute("CREATE TABLE sessions (id TEXT, cwd TEXT, started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
|
||||
conn.execute("INSERT INTO sessions (id, cwd) VALUES ('u-h1', ?)", (ws,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
assert hermes.discover(ctx_h) == ['u-h1']
|
||||
|
||||
# 4. Cline
|
||||
cline = get_adapter('cline')
|
||||
ctx_cl = DiscoveryContext(workspace=ws, agent_name='cline', home_dir=home)
|
||||
cl_sess = f"{home}/.cline/data/sessions/u-cl1"
|
||||
os.makedirs(cl_sess, exist_ok=True)
|
||||
with open(f"{cl_sess}/u-cl1.json", 'w') as f:
|
||||
f.write('{"session_id": "u-cl1", "cwd": "' + ws + '"}')
|
||||
assert cline.discover(ctx_cl) == ['u-cl1']
|
||||
|
||||
@@ -219,20 +219,6 @@ def test_o10_revalidate_normal_subagent(mam_sandbox):
|
||||
assert run_verify_uuid(str(mam_sandbox), "claude", sub_uuid, row=row, mode="revalidate", env=env)
|
||||
|
||||
|
||||
# O-11: verify_session_uuid respects isolation root
|
||||
def test_o11_isolation_root_respected(mam_sandbox):
|
||||
orc_uuid = "01eae7cf-1db6-4395-ba48-5fb02f4b6b1f"
|
||||
iso_root = mam_sandbox / "iso"
|
||||
make_claude_transcript(mam_sandbox, orc_uuid, target_dir=iso_root)
|
||||
|
||||
row = {
|
||||
"name": "my-orc",
|
||||
"claude_session_id_own": orc_uuid,
|
||||
"isolation": {"root": str(iso_root), "uuid": "iso-1"},
|
||||
"pane": {"cwd": str(mam_sandbox)}
|
||||
}
|
||||
env = {"AGENT_SESSIONS_YAML": str(mam_sandbox / ".mam" / "agent-sessions.yaml"), "HOME_DIR": str(mam_sandbox)}
|
||||
assert run_verify_uuid(str(mam_sandbox), "claude", orc_uuid, row=row, mode="revalidate", env=env)
|
||||
|
||||
|
||||
# O-12: orc_onboard.sh --uuid explicitly adds UUID
|
||||
|
||||
@@ -293,31 +293,9 @@ d['herdr_sessions'] = [{
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# FEATURE 3: Stop Session (5 Test Cases)
|
||||
# FEATURE 3: Stop Session (4 Test Cases)
|
||||
# ==============================================================================
|
||||
|
||||
def test_comp_stop_safe_path_checking(mam_sandbox):
|
||||
"""Verify path guards block directory deletion if isolation path check fails."""
|
||||
# Mock a terminated session where isolation root is set outside .mam folder
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'test-purge-guard-creator-claude',
|
||||
'status': 'running',
|
||||
'pane': {'cwd': 'WS_PLACEHOLDER'},
|
||||
'isolation': {
|
||||
'uuid': 'some-uuid',
|
||||
'root': '/tmp/unauthorized_path_outside_mam'
|
||||
}
|
||||
}]
|
||||
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
|
||||
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||
# Attempt to purge. The python script should print "WARN: isolated home path check failed" and NOT crash
|
||||
res = subprocess.run(["bash", str(script_path), "--session", "test-purge-guard-creator-claude", "--purge-conversation", "--yes"], capture_output=True, text=True)
|
||||
assert res.returncode == 0
|
||||
assert "WARN: isolated home path check failed" in res.stdout
|
||||
|
||||
def test_comp_stop_sqlite_state_update(mam_sandbox):
|
||||
"""Verify that stop_session.sh updates state in SQLite to stopped."""
|
||||
mutation = """
|
||||
|
||||
@@ -342,44 +342,6 @@ def test_t10_resume_workspace_paths(mam_sandbox, mock_herdr, mock_agents):
|
||||
assert f"would spawn:" in res.stdout
|
||||
|
||||
|
||||
def test_t11_legacy_isolation_row(mam_sandbox, mock_herdr, mock_agents):
|
||||
"""T-11: legacy isolation row resolved from isolation.root"""
|
||||
iso_root = str(mam_sandbox / "iso_root")
|
||||
ws = str(mam_sandbox / "iso_ws")
|
||||
os.makedirs(ws, exist_ok=True)
|
||||
|
||||
ws_key = os.path.realpath(ws).replace("/", "-").replace("_", "-")
|
||||
proj_dir = os.path.join(iso_root, "projects", ws_key)
|
||||
os.makedirs(proj_dir, exist_ok=True)
|
||||
u_val = str(uuid.uuid4())
|
||||
with open(os.path.join(proj_dir, f"{u_val}.jsonl"), "w") as f:
|
||||
f.write(json.dumps({"type": "queue-operation", "sessionId": u_val}) + "\n")
|
||||
f.write(json.dumps({"type": "user", "sessionId": u_val, "cwd": ws}) + "\n")
|
||||
|
||||
mutation = f"""
|
||||
entry = {{
|
||||
'name': 'iso-session',
|
||||
'status': 'stopped',
|
||||
'role': 'creator',
|
||||
'claude_session_id_own': '{u_val}',
|
||||
'isolation': {{'root': '{iso_root}', 'uuid': 'iso-uuid-1'}},
|
||||
'pane': {{'cwd': '{ws}'}}
|
||||
}}
|
||||
d.setdefault('herdr_sessions', []).append(entry)
|
||||
"""
|
||||
res_m = run_mutation(mam_sandbox, mutation)
|
||||
assert res_m.returncode == 0, f"mutation failed: {res_m.stderr}"
|
||||
|
||||
resolve_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "resolve_session_id.sh"
|
||||
cmd = [
|
||||
"bash", str(resolve_script),
|
||||
"--workspace", ws,
|
||||
"--agent", "claude",
|
||||
"--session", "iso-session"
|
||||
]
|
||||
res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(mam_sandbox))
|
||||
assert res.returncode == 0
|
||||
assert res.stdout.strip() == u_val
|
||||
|
||||
|
||||
def test_t12_other_workspace_assigned_row_revalidate_fails(mam_sandbox):
|
||||
|
||||
Reference in New Issue
Block a user