mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
## Summary - Adds `AutogptCopilotBlock` that invokes the platform's copilot system (`stream_chat_completion_sdk`) directly from graph executions - Enables sub-agent patterns: copilot can call this block recursively (with depth limiting via `contextvars`) - Enables scheduled copilot execution through the agent executor system - No user credentials needed — uses server-side copilot config ## Inputs/Outputs **Inputs:** prompt, system_context, session_id (continuation), timeout, max_recursion_depth **Outputs:** response text, tool_calls list, conversation_history JSON, session_id, token_usage ## Test plan - [x] Block test passes (`test_available_blocks[AutogptCopilotBlock]`) - [x] Pre-commit hooks pass (format, lint, typecheck) - [ ] Manual test: add block to graph, send prompt, verify response - [ ] Manual test: chain two copilot blocks with session_id to verify continuation
224 lines
8.1 KiB
Python
224 lines
8.1 KiB
Python
"""Tests for AutoPilotBlock: recursion guard, streaming, validation, and error paths."""
|
|
|
|
import asyncio
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from backend.blocks.autopilot import (
|
|
AUTOPILOT_BLOCK_ID,
|
|
AutoPilotBlock,
|
|
_autopilot_recursion_depth,
|
|
_autopilot_recursion_limit,
|
|
_check_recursion,
|
|
_reset_recursion,
|
|
)
|
|
from backend.data.execution import ExecutionContext
|
|
|
|
|
|
def _make_context(user_id: str = "test-user-123") -> ExecutionContext:
|
|
"""Helper to build an ExecutionContext for tests."""
|
|
return ExecutionContext(
|
|
user_id=user_id,
|
|
graph_id="graph-1",
|
|
graph_exec_id="gexec-1",
|
|
graph_version=1,
|
|
node_id="node-1",
|
|
node_exec_id="nexec-1",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Recursion guard unit tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCheckRecursion:
|
|
"""Unit tests for _check_recursion / _reset_recursion."""
|
|
|
|
def test_first_call_increments_depth(self):
|
|
tokens = _check_recursion(3)
|
|
try:
|
|
assert _autopilot_recursion_depth.get() == 1
|
|
assert _autopilot_recursion_limit.get() == 3
|
|
finally:
|
|
_reset_recursion(tokens)
|
|
|
|
def test_reset_restores_previous_values(self):
|
|
assert _autopilot_recursion_depth.get() == 0
|
|
assert _autopilot_recursion_limit.get() is None
|
|
tokens = _check_recursion(5)
|
|
_reset_recursion(tokens)
|
|
assert _autopilot_recursion_depth.get() == 0
|
|
assert _autopilot_recursion_limit.get() is None
|
|
|
|
def test_exceeding_limit_raises(self):
|
|
t1 = _check_recursion(2)
|
|
try:
|
|
t2 = _check_recursion(2)
|
|
try:
|
|
with pytest.raises(RuntimeError, match="recursion depth limit"):
|
|
_check_recursion(2)
|
|
finally:
|
|
_reset_recursion(t2)
|
|
finally:
|
|
_reset_recursion(t1)
|
|
|
|
def test_nested_calls_respect_inherited_limit(self):
|
|
"""Inner call with higher max_depth still respects outer limit."""
|
|
t1 = _check_recursion(2) # sets limit=2
|
|
try:
|
|
t2 = _check_recursion(10) # inner wants 10, but inherited is 2
|
|
try:
|
|
# depth is now 2, limit is min(10, 2) = 2 → should raise
|
|
with pytest.raises(RuntimeError, match="recursion depth limit"):
|
|
_check_recursion(10)
|
|
finally:
|
|
_reset_recursion(t2)
|
|
finally:
|
|
_reset_recursion(t1)
|
|
|
|
def test_limit_of_one_blocks_immediately_on_second_call(self):
|
|
t1 = _check_recursion(1)
|
|
try:
|
|
with pytest.raises(RuntimeError):
|
|
_check_recursion(1)
|
|
finally:
|
|
_reset_recursion(t1)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AutoPilotBlock.run() validation tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRunValidation:
|
|
"""Tests for input validation in AutoPilotBlock.run()."""
|
|
|
|
@pytest.fixture
|
|
def block(self):
|
|
return AutoPilotBlock()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_prompt_yields_error(self, block):
|
|
block.Input # ensure schema is accessible
|
|
input_data = block.Input(prompt=" ", max_recursion_depth=3)
|
|
ctx = _make_context()
|
|
outputs = {}
|
|
async for name, value in block.run(input_data, execution_context=ctx):
|
|
outputs[name] = value
|
|
assert outputs.get("error") == "Prompt cannot be empty."
|
|
assert "response" not in outputs
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_user_id_yields_error(self, block):
|
|
input_data = block.Input(prompt="hello", max_recursion_depth=3)
|
|
ctx = _make_context(user_id="")
|
|
outputs = {}
|
|
async for name, value in block.run(input_data, execution_context=ctx):
|
|
outputs[name] = value
|
|
assert "authenticated user" in outputs.get("error", "")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_successful_run_yields_all_outputs(self, block):
|
|
"""With execute_copilot mocked, run() should yield all 5 success outputs."""
|
|
mock_result = (
|
|
"Hello world",
|
|
[],
|
|
'[{"role":"user","content":"hi"}]',
|
|
"sess-abc",
|
|
{"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
|
|
)
|
|
block.execute_copilot = AsyncMock(return_value=mock_result)
|
|
block.create_session = AsyncMock(return_value="sess-abc")
|
|
|
|
input_data = block.Input(prompt="hi", max_recursion_depth=3)
|
|
ctx = _make_context()
|
|
outputs = {}
|
|
async for name, value in block.run(input_data, execution_context=ctx):
|
|
outputs[name] = value
|
|
|
|
assert outputs["response"] == "Hello world"
|
|
assert outputs["tool_calls"] == []
|
|
assert outputs["session_id"] == "sess-abc"
|
|
assert outputs["token_usage"]["total_tokens"] == 15
|
|
assert "error" not in outputs
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exception_yields_error(self, block):
|
|
"""On unexpected failure, run() should yield an error output."""
|
|
block.execute_copilot = AsyncMock(side_effect=RuntimeError("boom"))
|
|
block.create_session = AsyncMock(return_value="sess-fail")
|
|
|
|
input_data = block.Input(prompt="do something", max_recursion_depth=3)
|
|
ctx = _make_context()
|
|
outputs = {}
|
|
async for name, value in block.run(input_data, execution_context=ctx):
|
|
outputs[name] = value
|
|
|
|
assert outputs["session_id"] == "sess-fail"
|
|
assert "boom" in outputs.get("error", "")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_error_yields_error_and_reraises(self, block):
|
|
"""CancelledError should yield error, then re-raise."""
|
|
block.execute_copilot = AsyncMock(side_effect=asyncio.CancelledError())
|
|
block.create_session = AsyncMock(return_value="sess-cancel")
|
|
|
|
input_data = block.Input(prompt="do something", max_recursion_depth=3)
|
|
ctx = _make_context()
|
|
outputs = {}
|
|
with pytest.raises(asyncio.CancelledError):
|
|
async for name, value in block.run(input_data, execution_context=ctx):
|
|
outputs[name] = value
|
|
|
|
assert outputs["session_id"] == "sess-cancel"
|
|
assert "cancelled" in outputs.get("error", "").lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_existing_session_id_skips_create(self, block):
|
|
"""When session_id is provided, create_session should not be called."""
|
|
mock_result = (
|
|
"ok",
|
|
[],
|
|
"[]",
|
|
"existing-sid",
|
|
{"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
|
)
|
|
block.execute_copilot = AsyncMock(return_value=mock_result)
|
|
block.create_session = AsyncMock()
|
|
|
|
input_data = block.Input(
|
|
prompt="test", session_id="existing-sid", max_recursion_depth=3
|
|
)
|
|
ctx = _make_context()
|
|
async for _ in block.run(input_data, execution_context=ctx):
|
|
pass
|
|
|
|
block.create_session.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Block registration / ID tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBlockRegistration:
|
|
def test_block_id_matches_constant(self):
|
|
block = AutoPilotBlock()
|
|
assert block.id == AUTOPILOT_BLOCK_ID
|
|
|
|
def test_max_recursion_depth_has_upper_bound(self):
|
|
"""Schema should enforce le=10."""
|
|
schema = AutoPilotBlock.Input.model_json_schema()
|
|
max_rec = schema["properties"]["max_recursion_depth"]
|
|
assert (
|
|
max_rec.get("maximum") == 10 or max_rec.get("exclusiveMaximum", 999) <= 11
|
|
)
|
|
|
|
def test_output_schema_has_no_duplicate_error_field(self):
|
|
"""Output should inherit error from BlockSchemaOutput, not redefine it."""
|
|
# The field should exist (inherited) but there should be no explicit
|
|
# redefinition. We verify by checking the class __annotations__ directly.
|
|
assert "error" not in AutoPilotBlock.Output.__annotations__
|