# SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 from __future__ import annotations import io import json import os import subprocess import sys import time import pytest import unsloth_cli.claude_subagent_mcp as bridge def _stub_env(monkeypatch, tmp_path): """Minimum env + claude lookup for driving run_local_agent under a fake Popen.""" monkeypatch.setenv("UNSLOTH_CLAUDE_SUBAGENT_BASE_URL", "http://127.0.0.1:8888") monkeypatch.setenv("UNSLOTH_CLAUDE_SUBAGENT_API_KEY", "sk-unsloth-test") monkeypatch.setenv("UNSLOTH_CLAUDE_SUBAGENT_MODEL", "unsloth/model-GGUF:Q4_K_M") monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(tmp_path)) monkeypatch.setattr(bridge.shutil, "which", lambda _: "/usr/local/bin/claude") monkeypatch.setattr(bridge, "_claude_flags", lambda model: ["--settings", "{}"]) def test_protocol_lists_and_calls_local_agent(): initialized = bridge._response( {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}, ) assert initialized["result"]["serverInfo"]["name"] == "unsloth-local-agent" listed = bridge._response({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}) tool = listed["result"]["tools"][0] assert tool["name"] == "unsloth_agent" assert "spawn an Unsloth or local agent" in tool["description"] assert tool["inputSchema"]["required"] == ["task"] assert tool["_meta"]["anthropic/maxResultSizeChars"] == 100_000 called = bridge._response( { "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "unsloth_agent", "arguments": {"task": " inspect this "}}, }, run_agent = lambda task: f"completed: {task}", ) assert called["result"] == { "content": [{"type": "text", "text": "completed: inspect this"}], "isError": False, } def test_protocol_exposes_read_only_agent_for_claude_plan_mode(): listed = bridge._response( {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, run_read_only_agent = lambda task: task, read_only_tool_name = "unsloth_plan_agent", ) tools = {tool["name"]: tool for tool in listed["result"]["tools"]} assert tools["unsloth_agent"]["annotations"]["readOnlyHint"] is False assert tools["unsloth_plan_agent"]["annotations"] == { "readOnlyHint": True, "destructiveHint": False, "idempotentHint": True, "openWorldHint": True, } called = bridge._response( { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "unsloth_plan_agent", "arguments": {"task": " inspect this "}, }, }, run_agent = lambda task: f"write: {task}", run_read_only_agent = lambda task: f"plan: {task}", read_only_tool_name = "unsloth_plan_agent", ) assert called["result"] == { "content": [{"type": "text", "text": "plan: inspect this"}], "isError": False, } def test_protocol_returns_tool_errors_to_parent(): response = bridge._response( { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "unsloth_agent", "arguments": {"task": "test"}}, }, run_agent = lambda task: (_ for _ in ()).throw(RuntimeError("local failure")), ) assert response["result"]["isError"] is True assert response["result"]["content"][0]["text"] == "local failure" def test_stdio_server_ignores_notifications_and_answers_requests(): requests = "\n".join( [ json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}), json.dumps({"jsonrpc": "2.0", "id": 4, "method": "ping"}), ] ) output = io.StringIO() bridge.serve(io.StringIO(requests), output) assert json.loads(output.getvalue()) == {"jsonrpc": "2.0", "id": 4, "result": {}} def test_stdio_cancellation_reaches_the_running_local_agent(): requests = "\n".join( [ json.dumps( { "jsonrpc": "2.0", "id": "call-1", "method": "tools/call", "params": {"name": "unsloth_agent", "arguments": {"task": "wait"}}, } ), json.dumps( { "jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": "call-1", "reason": "user cancelled"}, } ), ] ) output = io.StringIO() cancelled = [] def run_agent(task, cancel_event): assert task == "wait" assert cancel_event.wait(timeout = 1) cancelled.append(task) raise RuntimeError("The local Claude agent was cancelled.") bridge.serve(io.StringIO(requests), output, run_agent = run_agent) assert cancelled == ["wait"] assert output.getvalue() == "" def test_stdio_sigint_stops_the_running_local_agent(monkeypatch): request = json.dumps( { "jsonrpc": "2.0", "id": "call-1", "method": "tools/call", "params": {"name": "unsloth_agent", "arguments": {"task": "wait"}}, } ) handlers = {} started = bridge.threading.Event() cancelled = [] def set_handler(signum, handler): previous = handlers.get(signum, bridge.signal.SIG_DFL) handlers[signum] = handler return previous monkeypatch.setattr(bridge.signal, "signal", set_handler) class InterruptingInput: def __init__(self): self.sent = False def __iter__(self): return self def __next__(self): if not self.sent: self.sent = True return request + "\n" assert started.wait(timeout = 1) handlers[bridge.signal.SIGINT](bridge.signal.SIGINT, None) raise AssertionError("SIGINT handler must unwind the stdin loop") def run_agent(task, cancel_event): assert task == "wait" started.set() assert cancel_event.wait(timeout = 1) # Real Claude Code sends SIGINT twice. The second one must not abort cleanup. handlers[bridge.signal.SIGINT](bridge.signal.SIGINT, None) cancelled.append(task) raise RuntimeError("The local Claude agent was cancelled.") output = io.StringIO() bridge.serve(InterruptingInput(), output, run_agent = run_agent) assert cancelled == ["wait"] assert output.getvalue() == "" @pytest.mark.parametrize( ("bypass", "permission"), [("0", "acceptEdits"), ("1", "bypassPermissions")], ) def test_local_child_uses_unsloth_without_overwriting_parent_auth( monkeypatch, tmp_path, bypass, permission ): captured = {} monkeypatch.setenv("UNSLOTH_CLAUDE_SUBAGENT_BASE_URL", "http://127.0.0.1:8888") monkeypatch.setenv("UNSLOTH_CLAUDE_SUBAGENT_API_KEY", "sk-unsloth-test") monkeypatch.setenv("UNSLOTH_CLAUDE_SUBAGENT_MODEL", "unsloth/model-GGUF:Q4_K_M") monkeypatch.setenv("UNSLOTH_CLAUDE_SUBAGENT_CONTEXT_WINDOW", "32768") monkeypatch.setenv("UNSLOTH_CLAUDE_SUBAGENT_BYPASS_PERMISSIONS", bypass) monkeypatch.setenv("ANTHROPIC_API_KEY", "cloud-key") monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "cloud-oauth") monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(tmp_path)) monkeypatch.setattr(bridge.shutil, "which", lambda _: "/usr/local/bin/claude") monkeypatch.setattr(bridge, "_claude_flags", lambda model: ["--settings", "{}"]) class Process: pid = 1234 returncode = 0 def communicate(self, timeout): captured["timeout"] = timeout return json.dumps({"is_error": False, "result": "LOCAL_OK"}), "" def poll(self): return self.returncode def popen(command, **kwargs): captured["command"] = command captured.update(kwargs) return Process() monkeypatch.setattr(bridge.subprocess, "Popen", popen) assert bridge.run_local_agent("reply exactly LOCAL_OK") == "LOCAL_OK" command = captured["command"] assert command[:3] == ["/usr/local/bin/claude", "--model", "unsloth/model-GGUF:Q4_K_M"] assert command[command.index("--permission-mode") + 1] == permission assert "--no-session-persistence" in command disallowed = command[command.index("--disallowedTools") + 1] assert disallowed == "AskUserQuestion,EnterPlanMode,ExitPlanMode" assert captured["cwd"] == str(tmp_path) assert captured["stdin"] is bridge.subprocess.DEVNULL assert captured["stdout"] is bridge.subprocess.PIPE assert captured["stderr"] is bridge.subprocess.PIPE if os.name == "nt": assert captured["creationflags"] == bridge.subprocess.CREATE_NEW_PROCESS_GROUP else: assert captured["start_new_session"] is True child_env = captured["env"] assert child_env["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8888" assert child_env["ANTHROPIC_AUTH_TOKEN"] == "sk-unsloth-test" assert child_env["ANTHROPIC_MODEL"] == "unsloth/model-GGUF:Q4_K_M" assert child_env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] == "32768" assert child_env["CLAUDE_AUTOCOMPACT_PCT_OVERRIDE"] == "90" assert "ANTHROPIC_API_KEY" not in child_env assert "CLAUDE_CODE_OAUTH_TOKEN" not in child_env def test_read_only_local_child_uses_plan_mode(monkeypatch, tmp_path): captured = {} monkeypatch.setenv("UNSLOTH_CLAUDE_SUBAGENT_BASE_URL", "http://127.0.0.1:8888") monkeypatch.setenv("UNSLOTH_CLAUDE_SUBAGENT_API_KEY", "sk-unsloth-test") monkeypatch.setenv("UNSLOTH_CLAUDE_SUBAGENT_MODEL", "unsloth/model-GGUF:Q4_K_M") monkeypatch.setenv("UNSLOTH_CLAUDE_SUBAGENT_BYPASS_PERMISSIONS", "1") monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(tmp_path)) monkeypatch.setattr(bridge.shutil, "which", lambda _: "/usr/local/bin/claude") monkeypatch.setattr(bridge, "_claude_flags", lambda model: []) class Process: pid = 1234 returncode = 0 def communicate(self, timeout): return json.dumps({"is_error": False, "result": "PLAN_OK"}), "" def poll(self): return self.returncode def popen(command, **kwargs): captured["command"] = command return Process() monkeypatch.setattr(bridge.subprocess, "Popen", popen) assert bridge.run_local_agent("plan this", read_only = True) == "PLAN_OK" command = captured["command"] assert command[command.index("--permission-mode") + 1] == "plan" disallowed = command[command.index("--disallowedTools") + 1] assert disallowed == "AskUserQuestion,EnterPlanMode,Edit,Write,NotebookEdit,Bash" # Bash matters: plan mode routes it through a classifier served by this same # local model, so without the deny a "read-only" child can still write files. prompt = command[command.index("--append-system-prompt") + 1] assert "read-only local coding subagent" in prompt def test_local_child_process_is_stopped_on_cancellation(monkeypatch, tmp_path): monkeypatch.setenv("UNSLOTH_CLAUDE_SUBAGENT_BASE_URL", "http://127.0.0.1:8888") monkeypatch.setenv("UNSLOTH_CLAUDE_SUBAGENT_API_KEY", "sk-unsloth-test") monkeypatch.setenv("UNSLOTH_CLAUDE_SUBAGENT_MODEL", "unsloth/model-GGUF:Q4_K_M") monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(tmp_path)) monkeypatch.setattr(bridge.shutil, "which", lambda _: "/usr/local/bin/claude") monkeypatch.setattr(bridge, "_claude_flags", lambda model: []) cancel_event = bridge.threading.Event() stopped = [] class Process: pid = 1234 returncode = None def communicate(self, timeout): cancel_event.set() raise bridge.subprocess.TimeoutExpired("claude", timeout) def poll(self): return self.returncode process = Process() monkeypatch.setattr(bridge.subprocess, "Popen", lambda *args, **kwargs: process) def stop(child): stopped.append(child) child.returncode = -15 monkeypatch.setattr(bridge, "_stop_child", stop) with pytest.raises(RuntimeError, match = "cancelled"): bridge.run_local_agent("wait", cancel_event) assert stopped == [process] def test_windows_cancellation_stops_the_child_process_tree(monkeypatch): monkeypatch.setattr(bridge.os, "name", "nt") captured = {} class Process: pid = 4321 returncode = None def poll(self): return self.returncode def wait(self, timeout = None): captured["wait_timeout"] = timeout self.returncode = 1 def terminate(self): raise AssertionError("taskkill should handle the process tree") def run(command, **kwargs): captured["command"] = command captured.update(kwargs) return bridge.subprocess.CompletedProcess(command, 0) monkeypatch.setattr(bridge.subprocess, "run", run) bridge._stop_child(Process()) assert captured["command"] == ["taskkill", "/PID", "4321", "/T", "/F"] assert captured["capture_output"] is True assert captured["check"] is False assert captured["wait_timeout"] == bridge._CANCEL_GRACE_SECONDS def test_windows_failed_taskkill_still_terminates_the_child(monkeypatch): monkeypatch.setattr(bridge.os, "name", "nt") captured = {} class Process: pid = 4321 returncode = None def poll(self): return self.returncode def wait(self, timeout = None): self.returncode = 1 def terminate(self): captured["terminated"] = True self.returncode = 1 monkeypatch.setattr( bridge.subprocess, "run", lambda command, **kwargs: bridge.subprocess.CompletedProcess(command, 1), ) bridge._stop_child(Process()) assert captured.get("terminated") is True @pytest.mark.skipif(os.name == "nt", reason = "POSIX process groups") def test_stop_child_kills_survivors_after_leader_exit(monkeypatch, tmp_path): monkeypatch.setattr(bridge, "_CANCEL_GRACE_SECONDS", 0.2) marker = tmp_path / "grandchild-survived" grandchild = ( "import pathlib, sys, time; time.sleep(1.0); " "pathlib.Path(sys.argv[1]).write_text('alive')" ) process = subprocess.Popen( [ sys.executable, "-c", "import subprocess, sys; " "subprocess.Popen([sys.executable, '-c', sys.argv[1], sys.argv[2]])", grandchild, str(marker), ], start_new_session = True, ) process.wait() bridge._stop_child(process) time.sleep(1.2) assert not marker.exists() def test_result_parser_accepts_diagnostics_before_json(): output = "connector warning\n" + json.dumps({"is_error": False, "result": "OK"}) assert bridge._result_text(output) == "OK" def test_child_is_stopped_when_it_produces_nothing_before_the_deadline(monkeypatch, tmp_path): # A local server that accepts and never answers used to block the child, and # the parent waiting on it, indefinitely. Measured past 400s before this. monkeypatch.setenv("UNSLOTH_CLAUDE_SUBAGENT_TIMEOUT", "0.3") _stub_env(monkeypatch, tmp_path) stopped = [] class _Hanging: returncode = None def communicate(self, timeout = None): raise subprocess.TimeoutExpired("claude", timeout) def poll(self): return None monkeypatch.setattr(bridge, "_stop_child", lambda proc: stopped.append(proc)) monkeypatch.setattr(bridge.subprocess, "Popen", lambda *a, **k: _Hanging()) with pytest.raises(RuntimeError, match = "produced nothing"): bridge.run_local_agent("hello") assert stopped, "a timed-out child must be killed, not left running" def test_timeout_can_be_disabled(monkeypatch): monkeypatch.setenv("UNSLOTH_CLAUDE_SUBAGENT_TIMEOUT", "0") assert bridge._timeout_seconds() == 0.0 for bad in ("", " ", "abc"): monkeypatch.setenv("UNSLOTH_CLAUDE_SUBAGENT_TIMEOUT", bad) assert bridge._timeout_seconds() == bridge._DEFAULT_TIMEOUT_SECONDS monkeypatch.delenv("UNSLOTH_CLAUDE_SUBAGENT_TIMEOUT") assert bridge._timeout_seconds() == bridge._DEFAULT_TIMEOUT_SECONDS