mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-30 20:35:22 +08:00
371 lines
14 KiB
Python
371 lines
14 KiB
Python
"""Native process fixtures use Python, not vendor CLI credentials/binaries."""
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import importlib.util
|
|
import os
|
|
import signal
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(os.environ.get("CODING_RUNNER_SOURCE_ROOT") or Path(__file__).resolve().parents[1])
|
|
FOLDERS = ("acp-agent-runner", "claude-code-agent", "codex-agent")
|
|
|
|
|
|
def load_plugin(folder, relative=None):
|
|
old = {n: m for n, m in sys.modules.items() if n == "pkg" or n.startswith("pkg.")}
|
|
for name in old:
|
|
del sys.modules[name]
|
|
sys.path.insert(0, str(ROOT / folder))
|
|
try:
|
|
relative = relative or ("components/runner/default.py" if folder == FOLDERS[0] else "pkg/native_cli.py")
|
|
spec = importlib.util.spec_from_file_location(folder.replace("-", "_"), ROOT / folder / relative)
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[spec.name] = module
|
|
spec.loader.exec_module(module)
|
|
modules = {n: m for n, m in sys.modules.items() if n == "pkg" or n.startswith("pkg.")}
|
|
return module, modules
|
|
finally:
|
|
sys.path.pop(0)
|
|
for name in list(sys.modules):
|
|
if name == "pkg" or name.startswith("pkg."):
|
|
del sys.modules[name]
|
|
sys.modules.update(old)
|
|
|
|
|
|
def live(pid):
|
|
try:
|
|
return Path(f"/proc/{pid}/stat").read_text().split(") ", 1)[1].split()[0] != "Z"
|
|
except FileNotFoundError:
|
|
return False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("folder", FOLDERS)
|
|
async def test_native_cancel_awaits_child_and_grandchild_exit(folder, tmp_path):
|
|
module, modules = load_plugin(folder)
|
|
pids = tmp_path / "pids"
|
|
code = (
|
|
"import os,subprocess,sys,time,signal; "
|
|
"child=subprocess.Popen([sys.executable,'-c','import signal,time; signal.signal(signal.SIGTERM,signal.SIG_IGN); time.sleep(60)']); "
|
|
f"open({str(pids)!r},'w').write(str(os.getpid())+' '+str(child.pid)); "
|
|
"time.sleep(60)"
|
|
)
|
|
if folder == FOLDERS[0]:
|
|
client = modules["pkg.acp_client"].AcpStdioClient(command=sys.executable, args=["-c", code], cwd=str(tmp_path))
|
|
|
|
async def run():
|
|
async with client:
|
|
await asyncio.sleep(60)
|
|
else:
|
|
|
|
async def run():
|
|
kwargs = dict(cwd=str(tmp_path), env=dict(os.environ), timeout=60, streaming=True)
|
|
if folder == FOLDERS[1]:
|
|
kwargs["expected_session_id"] = ""
|
|
else:
|
|
kwargs.update(resume_session_id="", prompt="fixture", agent_cwd=str(tmp_path))
|
|
async for _ in module._run_cli_process_events(sys.executable, ["-c", code], **kwargs):
|
|
pass
|
|
|
|
task = asyncio.create_task(run())
|
|
ids = []
|
|
try:
|
|
async with asyncio.timeout(4):
|
|
while not pids.exists():
|
|
await asyncio.sleep(0.01)
|
|
ids = list(map(int, pids.read_text().split()))
|
|
assert all(live(pid) for pid in ids)
|
|
task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await asyncio.wait_for(task, 10)
|
|
assert not any(live(pid) for pid in ids), "native child/process group still running after cancel"
|
|
finally:
|
|
task.cancel()
|
|
await asyncio.gather(task, return_exceptions=True)
|
|
for pid in ids:
|
|
with contextlib.suppress(ProcessLookupError):
|
|
os.kill(pid, signal.SIGKILL)
|
|
|
|
|
|
@pytest.mark.parametrize("folder", FOLDERS)
|
|
def test_shared_workspace_defaults_and_dedicated_preserved(folder, tmp_path, monkeypatch):
|
|
module, modules = load_plugin(folder)
|
|
cls = getattr(
|
|
module,
|
|
{
|
|
"acp-agent-runner": "DefaultRunner",
|
|
"claude-code-agent": "NativeClaudeCodeRunner",
|
|
"codex-agent": "NativeCodexRunner",
|
|
}[folder],
|
|
)
|
|
from langbot_plugin.api.entities.builtin.runner import (
|
|
AgentEventContext,
|
|
AgentInput,
|
|
AgentResources,
|
|
AgentRunState,
|
|
AgentRuntimeContext,
|
|
AgentTrigger,
|
|
DeliveryContext,
|
|
RunnerContext,
|
|
)
|
|
|
|
ctx = RunnerContext(
|
|
run_id="same-run",
|
|
trigger=AgentTrigger(type="message.received"),
|
|
event=AgentEventContext(event_id="same-event", event_type="message.received", source="test"),
|
|
input=AgentInput(text="hello"),
|
|
delivery=DeliveryContext(surface="test"),
|
|
resources=AgentResources(),
|
|
state=AgentRunState(),
|
|
runtime=AgentRuntimeContext(),
|
|
config={"location": "local"},
|
|
)
|
|
runner = cls()
|
|
runner.bind_runtime(plugin_runtime_handler=object(), plugin_config={}, plugin_identity="fixture")
|
|
monkeypatch.delenv("LANGBOT_PLUGIN_RUNTIME_PROFILE", raising=False)
|
|
assert runner._validate_config(ctx)["workspace"] == os.getcwd()
|
|
monkeypatch.setenv("LANGBOT_PLUGIN_RUNTIME_PROFILE", "shared")
|
|
support = modules.get("pkg.runtime_support")
|
|
if support:
|
|
monkeypatch.setattr(support, "SHARED_DATA_ROOT", tmp_path)
|
|
config = runner._validate_config(ctx)
|
|
assert config["workspace"] == str(tmp_path / "workspace")
|
|
assert (tmp_path / "workspace").is_dir()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_codex_same_session_serialized_distinct_accounts_independent(tmp_path):
|
|
_, modules = load_plugin("codex-agent")
|
|
support = modules.get("pkg.runtime_support")
|
|
assert support is not None, "missing session serialization"
|
|
entered = []
|
|
release = asyncio.Event()
|
|
|
|
async def first():
|
|
async with support.session_guard(str(tmp_path / "account-A"), "session-1"):
|
|
entered.append("first")
|
|
await release.wait()
|
|
|
|
async def second(home):
|
|
async with support.session_guard(str(home), "session-1"):
|
|
entered.append(str(home.name))
|
|
|
|
a = asyncio.create_task(first())
|
|
await asyncio.sleep(0)
|
|
b = asyncio.create_task(second(tmp_path / "account-A"))
|
|
c = asyncio.create_task(second(tmp_path / "account-B"))
|
|
await asyncio.sleep(0.1)
|
|
try:
|
|
assert entered == ["first", "account-B"]
|
|
release.set()
|
|
await asyncio.gather(a, b, c)
|
|
assert entered[-1] == "account-A"
|
|
finally:
|
|
release.set()
|
|
await asyncio.gather(a, b, c, return_exceptions=True)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("folder", FOLDERS)
|
|
async def test_bundled_daemon_ack_only_after_native_group_exit(folder, tmp_path):
|
|
module, modules = load_plugin(folder, "daemon.py" if folder == FOLDERS[0] else None)
|
|
relay = modules["pkg.daemon_relay"]
|
|
cls = getattr(
|
|
module,
|
|
{
|
|
"acp-agent-runner": "RunnerDaemon",
|
|
"claude-code-agent": "NativeClaudeCodeDaemon",
|
|
"codex-agent": "NativeCodexDaemon",
|
|
}[folder],
|
|
)
|
|
marker = tmp_path / "child"
|
|
code = "import os,time; open(" + repr(str(marker)) + ",'w').write(str(os.getpid())); time.sleep(60)"
|
|
config = dict(
|
|
command=[sys.executable, "-c", code],
|
|
args=[],
|
|
acp_command=[sys.executable, "-c", code],
|
|
workspace=str(tmp_path),
|
|
langbot_assets_enabled=False,
|
|
env={"CODEX_HOME": str(tmp_path / "auth")},
|
|
)
|
|
hub = relay.AgentRuntimeDaemonHub()
|
|
await hub.start(host="127.0.0.1", port=0, token="fixture")
|
|
client = cls(url=hub.endpoint, daemon_id="native", token="fixture")
|
|
serving = asyncio.create_task(client.run_forever())
|
|
await hub.wait_for_daemon("native", 2)
|
|
|
|
async def consume():
|
|
return [
|
|
x
|
|
async for x in hub.run_job(
|
|
daemon_id="native",
|
|
payload=dict(config=config, prompt="fixture", prompt_text="fixture"),
|
|
tools=None,
|
|
timeout=30,
|
|
)
|
|
]
|
|
|
|
task = asyncio.create_task(consume())
|
|
pid = None
|
|
try:
|
|
async with asyncio.timeout(5):
|
|
while not marker.exists():
|
|
await asyncio.sleep(0.01)
|
|
pid = int(marker.read_text())
|
|
task.cancel()
|
|
await asyncio.sleep(0)
|
|
task.cancel() # cancellation cannot interrupt the acknowledgement barrier
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await asyncio.wait_for(task, 8)
|
|
assert not live(pid)
|
|
assert not hub._jobs
|
|
assert not hub._fenced, "a cooperative bundled client must acknowledge cleanup"
|
|
finally:
|
|
serving.cancel()
|
|
task.cancel()
|
|
await asyncio.gather(serving, task, return_exceptions=True)
|
|
if pid:
|
|
with contextlib.suppress(ProcessLookupError):
|
|
os.kill(pid, signal.SIGKILL)
|
|
await hub.stop()
|
|
|
|
|
|
def test_codex_auth_and_resume_continuity_with_distinct_account_homes(tmp_path):
|
|
module, _ = load_plugin("codex-agent")
|
|
homes = []
|
|
for owner in ("A", "B"):
|
|
account = tmp_path / owner
|
|
account.mkdir()
|
|
(account / "auth.json").write_text(owner)
|
|
(account / "sessions").mkdir()
|
|
(account / "sessions" / "resume.jsonl").write_text(owner)
|
|
env, _ = module._prepare_local_codex_home(
|
|
str(tmp_path / "project"), "same-session", {"CODEX_HOME": str(account)}, ""
|
|
)
|
|
homes.append(Path(env["CODEX_HOME"]))
|
|
assert (homes[-1] / "auth.json").read_text() == owner
|
|
assert (homes[-1] / "sessions" / "resume.jsonl").read_text() == owner
|
|
assert homes[0] != homes[1]
|
|
assert (homes[0] / "sessions").resolve() != (homes[1] / "sessions").resolve()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("folder", FOLDERS)
|
|
async def test_same_installation_two_agent_configs_and_reconfiguration(folder, tmp_path):
|
|
import json
|
|
import socket
|
|
|
|
import websockets
|
|
from langbot_plugin.api.entities.builtin.runner import (
|
|
AgentEventContext,
|
|
AgentInput,
|
|
AgentResources,
|
|
AgentRunState,
|
|
AgentRuntimeContext,
|
|
AgentTrigger,
|
|
DeliveryContext,
|
|
RunnerContext,
|
|
)
|
|
|
|
module, modules = load_plugin(folder)
|
|
relay = modules["pkg.daemon_relay"]
|
|
key = {"acp-agent-runner": "acp", "claude-code-agent": "claude-code", "codex-agent": "codex"}[folder]
|
|
cls = getattr(
|
|
module,
|
|
{
|
|
"acp-agent-runner": "DefaultRunner",
|
|
"claude-code-agent": "NativeClaudeCodeRunner",
|
|
"codex-agent": "NativeCodexRunner",
|
|
}[folder],
|
|
)
|
|
runner = cls()
|
|
with socket.socket() as sock:
|
|
sock.bind(("127.0.0.1", 0))
|
|
port = sock.getsockname()[1]
|
|
plugin_config = {"daemon-host": "127.0.0.1", "daemon-port": port, "daemon-token": "installation-fixture"}
|
|
runner.bind_runtime(plugin_runtime_handler=object(), plugin_config=plugin_config, plugin_identity="fixture")
|
|
hub = relay.get_agent_runtime_daemon_hub(key)
|
|
await hub.start(host="127.0.0.1", port=port, token="installation-fixture")
|
|
sockets = []
|
|
tasks = []
|
|
try:
|
|
for name in ("agent-A", "agent-B"):
|
|
ws = await websockets.connect(hub.endpoint)
|
|
sockets.append(ws)
|
|
await ws.send(
|
|
json.dumps(
|
|
dict(type="daemon.hello", daemon_id=name, token="installation-fixture", protocol="coding-relay-v1")
|
|
)
|
|
)
|
|
await ws.recv()
|
|
ctx = RunnerContext(
|
|
run_id=name,
|
|
trigger=AgentTrigger(type="message.received"),
|
|
event=AgentEventContext(event_id="event", event_type="message.received", source="test"),
|
|
input=AgentInput(text="test"),
|
|
delivery=DeliveryContext(surface="test"),
|
|
resources=AgentResources(),
|
|
state=AgentRunState(),
|
|
runtime=AgentRuntimeContext(),
|
|
config={
|
|
"location": "daemon",
|
|
"daemon-id": name,
|
|
"workspace": str(tmp_path / name),
|
|
"langbot-assets-enabled": False,
|
|
"env": {"ACCOUNT": name},
|
|
},
|
|
)
|
|
config = runner._validate_config(ctx)
|
|
args = (ctx, config, name, "") + ((False,) if key == "claude-code" else ())
|
|
|
|
async def consume(args):
|
|
return [e async for e in runner._run_daemon(*args)]
|
|
|
|
tasks.append(asyncio.create_task(consume(args)))
|
|
for index, ws in enumerate(sockets):
|
|
frame = json.loads(await asyncio.wait_for(ws.recv(), 2))
|
|
assert frame["payload"]["config"]["workspace"] == str(tmp_path / ("agent-A" if index == 0 else "agent-B"))
|
|
await ws.send(
|
|
json.dumps(
|
|
dict(
|
|
type="run.event",
|
|
job_id=frame["job_id"],
|
|
event=dict(type="run.completed", data=dict(finish_reason="stop")),
|
|
)
|
|
)
|
|
)
|
|
await ws.send(json.dumps(dict(type="run.finished", job_id=frame["job_id"])))
|
|
assert all(len(events) == 1 for events in await asyncio.gather(*tasks))
|
|
runner.bind_runtime(
|
|
plugin_runtime_handler=object(),
|
|
plugin_config={**plugin_config, "daemon-token": "replacement"},
|
|
plugin_identity="fixture",
|
|
)
|
|
config = runner._validate_config(ctx)
|
|
args = (ctx, config, "test", "") + ((False,) if key == "claude-code" else ())
|
|
with pytest.raises(relay.AgentRuntimeDaemonError, match="different config"):
|
|
await consume(args)
|
|
finally:
|
|
for t in tasks:
|
|
t.cancel()
|
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
for ws in sockets:
|
|
await ws.close()
|
|
await hub.stop()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cleanup_os_failure_never_becomes_successful_ack(monkeypatch):
|
|
_, modules = load_plugin("codex-agent")
|
|
support = modules["pkg.runtime_support"]
|
|
|
|
async def failure(process):
|
|
raise PermissionError("fixture kill denied")
|
|
|
|
monkeypatch.setattr(support, "_terminate", failure)
|
|
with pytest.raises(support.ProcessCleanupUnconfirmed):
|
|
await support.terminate_process_group(object())
|