import subprocess
import json
import sys
import time
import os
import re
import select
BIN = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "target/debug/recursive")
def start_acp():
proc = subprocess.Popen(
[BIN, "acp"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
)
return proc
def send_line(proc, obj):
line = json.dumps(obj)
proc.stdin.write(line + "\n")
proc.stdin.flush()
def recv_line(proc, timeout=15):
ready, _, _ = select.select([proc.stdout], [], [], timeout)
if not ready:
return None
line = proc.stdout.readline()
if not line:
return None
line = line.strip()
if not line:
return None
try:
return json.loads(line)
except json.JSONDecodeError:
return {"_raw": line}
def drain(proc, timeout=3):
lines = []
deadline = time.time() + timeout
while time.time() < deadline:
ready, _, _ = select.select([proc.stdout], [], [], 0.5)
if ready:
line = proc.stdout.readline()
if not line:
break
line = line.strip()
if line:
try:
lines.append(json.loads(line))
except json.JSONDecodeError:
lines.append({"_raw": line})
else:
break
return lines
def expect_error(resp, expected_code, label):
assert resp is not None, f"{label}: no response"
assert "error" in resp, f"{label}: expected error, got: {resp}"
assert resp["error"]["code"] == expected_code, \
f"{label}: expected code {expected_code}, got {resp['error']['code']}: {resp['error']}"
def test():
findings = []
help_out = subprocess.run([BIN, "--help"], capture_output=True, text=True)
assert "acp" in help_out.stdout, "AC-S1-17: 'acp' not in --help output"
acp_help = subprocess.run([BIN, "acp", "--help"], capture_output=True, text=True)
assert acp_help.returncode == 0, f"AC-S1-17: acp --help failed: {acp_help.stderr}"
print("PASS: AC-S1-17")
acp_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src/acp")
result = subprocess.run(["grep", "-r", "run_core|run_inner|RunCore", acp_dir],
capture_output=True, text=True)
assert result.returncode != 0, f"AC-S1-11: ACP code imports run_core/run_inner/RunCore:\n{result.stdout}"
print("PASS: AC-S1-11")
proc = start_acp()
time.sleep(0.3)
send_line(proc, {
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": 1,
"clientCapabilities": {},
"clientInfo": {"name": "test", "version": "0.1"}
}
})
resp = recv_line(proc)
assert resp is not None, "AC-S1-01: no response to initialize"
assert resp.get("jsonrpc") == "2.0", f"AC-S1-01: bad jsonrpc: {resp}"
assert resp.get("id") == 1, f"AC-S1-01: bad id: {resp}"
assert resp.get("result", {}).get("protocolVersion") == 1, f"AC-S1-01: bad protocolVersion: {resp}"
assert resp.get("result", {}).get("agentInfo", {}).get("name") == "recursive", f"AC-S1-01: bad agentInfo: {resp}"
caps = resp.get("result", {}).get("agentCapabilities", {})
req_keys = [
"promptCapabilities", "toolCallNotifications", "loadSession", "resume",
"fs.readTextFile", "fs.writeTextFile", "mcpCapabilities", "terminalCapabilities"
]
for k in req_keys:
assert k in caps, f"AC-S1-09: missing key '{k}' in capabilities: {list(caps.keys())}"
assert caps["terminalCapabilities"] == False, f"AC-S1-09: terminalCapabilities not false: {caps['terminalCapabilities']}"
for k in req_keys:
if k != "terminalCapabilities":
val = caps[k]
assert val is not None and val != False, f"AC-S1-09: {k} is falsy: {val}"
print("PASS: AC-S1-01, AC-S1-09")
notif = recv_line(proc)
assert notif is not None, "AC-S1-15: no initialized notification"
assert notif.get("jsonrpc") == "2.0", f"AC-S1-15: bad jsonrpc: {notif}"
assert notif.get("method") == "initialized", f"AC-S1-15: bad method: {notif}"
assert "id" not in notif, f"AC-S1-15: notification has id: {notif}"
print("PASS: AC-S1-15")
def do_session_new(cwd, req_id):
send_line(proc, {"jsonrpc": "2.0", "id": req_id, "method": "session/new", "params": {"cwd": cwd}})
resp = recv_line(proc)
if resp is None:
return None, "no response"
if "error" in resp:
return None, f"error: {resp['error']}"
return resp.get("result", {}).get("sessionId"), None
sid1, err = do_session_new("/tmp/test-sandbox", 2)
assert err is None, f"AC-S1-03: session/new 1 failed: {err}"
assert re.match(r"acp-sess-\d+", sid1), f"AC-S1-03: bad ID format: {sid1}"
sid2, err = do_session_new("/tmp/test-sandbox", 3)
assert err is None, f"AC-S1-03: session/new 2 failed: {err}"
assert sid2 != sid1, f"AC-S1-03: duplicate IDs: {sid1} == {sid2}"
assert re.match(r"acp-sess-\d+", sid2), f"AC-S1-03: bad ID2 format: {sid2}"
n1, n2 = int(sid1.split("-")[-1]), int(sid2.split("-")[-1])
assert n2 > n1, f"AC-S1-03: not sequential: {sid1} vs {sid2}"
sid3, err = do_session_new("/tmp/acp-other", 4)
assert err is None, f"AC-S1-03: session/new 3 failed: {err}"
assert sid3 not in (sid1, sid2), f"AC-S1-03: colliding ID: {sid3}"
print("PASS: AC-S1-03")
sid_bad, err = do_session_new("/tmp/nonexistent-xyz-12345", 5)
assert err is not None, f"AC-S1-04: expected error for nonexistent, got: {sid_bad}"
sid_file, err = do_session_new("/etc/hosts", 6)
assert err is not None, f"AC-S1-04: expected error for file, got: {sid_file}"
print("PASS: AC-S1-04")
def do_session_prompt(sid, prompt_val, req_id):
send_line(proc, {
"jsonrpc": "2.0", "id": req_id, "method": "session/prompt",
"params": {"sessionId": sid, "prompt": prompt_val}
})
resp = recv_line(proc, timeout=5)
return resp
send_line(proc, {"jsonrpc": "2.0", "id": 10, "method": "session/prompt", "params": {"sessionId": sid1}})
resp = recv_line(proc, timeout=5)
expect_error(resp, -32602, "AC-S1-16: missing prompt")
resp = do_session_prompt(sid1, [], 11)
expect_error(resp, -32602, "AC-S1-16: empty prompt")
resp = do_session_prompt(sid1, "plain string", 12)
expect_error(resp, -32602, "AC-S1-16: string prompt")
print("PASS: AC-S1-16")
resp = do_session_prompt("acp-sess-99999", [{"type": "text", "text": "hi"}], 13)
expect_error(resp, None, "AC-S1-10: unknown session") assert resp is not None and "error" in resp, "AC-S1-10: no error"
code = resp["error"]["code"]
assert -32099 <= code <= -32000, f"AC-S1-10: code {code} not in [-32099,-32000]"
print("PASS: AC-S1-10")
send_line(proc, {
"jsonrpc": "2.0", "id": 20, "method": "session/prompt",
"params": {"sessionId": sid1, "prompt": [{"type": "text", "text": "What is 2+2? Answer briefly."}]}
})
all_lines = []
got_result = False
deadline = time.time() + 45
while time.time() < deadline:
line = recv_line(proc, timeout=2)
if line is None:
if got_result:
break
continue
all_lines.append(line)
if line.get("id") == 20:
got_result = True
time.sleep(0.5)
extra = drain(proc, timeout=2)
all_lines.extend(extra)
break
assert len(all_lines) > 0, "AC-S1-05: no lines received"
print(f"AC-S1-05: received {len(all_lines)} lines")
updates = [l for l in all_lines if l.get("method") == "session/update"]
assert len(updates) > 0, "AC-S1-05: no session/update notifications"
for u in updates:
params = u.get("params", {})
assert params.get("sessionId") == sid1, f"AC-S1-05: wrong sessionId: {params.get('sessionId')}"
upd = params.get("update", {})
assert "messageId" in upd, f"AC-S1-05: no messageId in update: {list(upd.keys())}"
print("PASS: AC-S1-05")
for l in all_lines:
upd = l.get("params", {}).get("update", {})
su = upd.get("sessionUpdate")
assert su not in ("tool_call", "tool_call_update"), \
f"AC-S1-07: found forbidden {su} notification"
print("PASS: AC-S1-07")
end_turns = [l for l in all_lines
if l.get("params", {}).get("update", {}).get("sessionUpdate") == "end_turn"]
assert len(end_turns) > 0, "AC-S1-06: no end_turn notification"
last_et = end_turns[-1]
stop_reason = last_et["params"]["update"]["stopReason"]
assert stop_reason in ("end_turn", "max_turns", "cancelled", "error"), \
f"AC-S1-06: bad stopReason: {stop_reason}"
result_line = [l for l in all_lines if l.get("id") == 20]
assert len(result_line) > 0, "AC-S1-06: no result line"
result_sr = result_line[0].get("result", {}).get("stopReason")
assert result_sr == stop_reason, \
f"AC-S1-06: mismatch: result.stopReason={result_sr} vs notif.stopReason={stop_reason}"
print(f"PASS: AC-S1-06 (stopReason={stop_reason})")
stderr_data = proc.stderr.read()
sid_alt, err = do_session_new("/tmp/acp-alt-dir-for-test", 30)
assert err is None, f"AC-S1-12: session/new failed: {err}"
send_line(proc, {
"jsonrpc": "2.0", "id": 31, "method": "session/prompt",
"params": {"sessionId": sid_alt, "prompt": [{"type": "text", "text": "Hello"}]}
})
all_lines2 = []
deadline = time.time() + 45
while time.time() < deadline:
line = recv_line(proc, timeout=2)
if line is None:
if any(l.get("id") == 31 for l in all_lines2):
break
continue
all_lines2.append(line)
if line.get("id") == 31:
time.sleep(0.5)
extra = drain(proc, timeout=2)
all_lines2.extend(extra)
break
def get_first_msg_id(lines):
for l in lines:
upd = l.get("params", {}).get("update", {})
if upd.get("sessionUpdate") == "agent_message_chunk":
return upd.get("messageId")
return None
msgid1 = get_first_msg_id(all_lines)
msgid2 = get_first_msg_id(all_lines2)
print(f"AC-S1-12: session1 messageId = {msgid1}")
print(f"AC-S1-12: session2 messageId = {msgid2}")
assert msgid1 is not None, "AC-S1-12: no messageId in session 1"
assert re.match(r'^[0-9a-f]{12}$', msgid1), f"AC-S1-12: bad format: {msgid1}"
assert msgid2 is not None, "AC-S1-12: no messageId in session 2"
assert re.match(r'^[0-9a-f]{12}$', msgid2), f"AC-S1-12: bad format: {msgid2}"
print("PASS: AC-S1-12")
proc.stdin.close()
proc.wait(timeout=5)
proc2 = start_acp()
time.sleep(0.3)
proc2.stdin.write("not json\n")
proc2.stdin.flush()
resp = recv_line(proc2)
expect_error(resp, -32700, "AC-S1-14: invalid JSON")
proc2.stdin.write('{"id":1}\n')
proc2.stdin.flush()
resp = recv_line(proc2)
expect_error(resp, -32600, "AC-S1-14: missing jsonrpc")
send_line(proc2, {
"jsonrpc": "2.0", "id": 100, "method": "initialize",
"params": {
"protocolVersion": 1,
"clientCapabilities": {},
"clientInfo": {"name": "test", "version": "0.1"}
}
})
resp = recv_line(proc2)
assert resp is not None and "result" in resp, \
f"AC-S1-14: server dead after errors: {resp}"
print("PASS: AC-S1-14")
proc2.stdin.close()
proc2.wait(timeout=5)
print("\n=== ALL ACCEPTANCE TESTS PASSED ===")
return True
if __name__ == "__main__":
try:
test()
except AssertionError as e:
print(f"\nFAIL: {e}")
sys.exit(1)
except Exception as e:
import traceback
traceback.print_exc()
print(f"\nERROR: {e}")
sys.exit(1)