mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-27 04:20:09 +00:00
New hanzo-tasks package wrapping temporalio SDK with Hanzo conventions: - Client: connect, submit, get_result, cancel, signal, query - Worker: register workflows/activities, poll and execute - Pre-built workflows: AgentTaskWorkflow, PipelineWorkflow, FanOutWorkflow - Pluggable agent executor via set_agent_executor() - 28 tests passing
318 lines
9.0 KiB
Python
318 lines
9.0 KiB
Python
"""Hanzo Tasks test suite.
|
|
|
|
Unit tests for client config, worker registration, dataclasses,
|
|
workflow decorators, and activity executor wiring. No Temporal
|
|
server required.
|
|
"""
|
|
|
|
import inspect
|
|
from dataclasses import asdict
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
|
|
from hanzo_tasks import (
|
|
AgentTaskInput,
|
|
AgentTaskOutput,
|
|
AgentTaskWorkflow,
|
|
Client,
|
|
FanOutWorkflow,
|
|
PipelineWorkflow,
|
|
TasksConfig,
|
|
Worker,
|
|
WorkflowHandle,
|
|
execute_agent_task,
|
|
set_agent_executor,
|
|
)
|
|
|
|
|
|
# -- config ------------------------------------------------------------------
|
|
|
|
|
|
class TestTasksConfig:
|
|
def test_defaults(self):
|
|
cfg = TasksConfig()
|
|
assert cfg.address == "localhost:7233"
|
|
assert cfg.namespace == "hanzo"
|
|
assert cfg.tls is False
|
|
|
|
def test_custom(self):
|
|
cfg = TasksConfig(address="temporal.prod:7233", namespace="prod", tls=True)
|
|
assert cfg.address == "temporal.prod:7233"
|
|
assert cfg.namespace == "prod"
|
|
assert cfg.tls is True
|
|
|
|
|
|
# -- dataclasses -------------------------------------------------------------
|
|
|
|
|
|
class TestAgentTaskInput:
|
|
def test_defaults(self):
|
|
inp = AgentTaskInput(
|
|
space_id="sp-1",
|
|
agent_id="ag-1",
|
|
task_title="Fix bug",
|
|
task_prompt="Fix the null pointer in main.go",
|
|
)
|
|
assert inp.space_id == "sp-1"
|
|
assert inp.agent_id == "ag-1"
|
|
assert inp.task_title == "Fix bug"
|
|
assert inp.task_prompt == "Fix the null pointer in main.go"
|
|
assert inp.timeout_seconds == 3600
|
|
assert inp.max_retries == 3
|
|
|
|
def test_custom_timeout(self):
|
|
inp = AgentTaskInput(
|
|
space_id="sp-2",
|
|
agent_id="ag-2",
|
|
task_title="Deploy",
|
|
task_prompt="Deploy to prod",
|
|
timeout_seconds=600,
|
|
max_retries=1,
|
|
)
|
|
assert inp.timeout_seconds == 600
|
|
assert inp.max_retries == 1
|
|
|
|
def test_serializes_to_dict(self):
|
|
inp = AgentTaskInput(
|
|
space_id="sp-1",
|
|
agent_id="ag-1",
|
|
task_title="T",
|
|
task_prompt="P",
|
|
)
|
|
d = asdict(inp)
|
|
assert d["space_id"] == "sp-1"
|
|
assert d["agent_id"] == "ag-1"
|
|
assert d["task_title"] == "T"
|
|
assert d["task_prompt"] == "P"
|
|
assert d["timeout_seconds"] == 3600
|
|
assert d["max_retries"] == 3
|
|
assert len(d) == 6
|
|
|
|
|
|
class TestAgentTaskOutput:
|
|
def test_defaults(self):
|
|
out = AgentTaskOutput()
|
|
assert out.result == ""
|
|
assert out.error == ""
|
|
assert out.elapsed_seconds == 0.0
|
|
|
|
def test_success(self):
|
|
out = AgentTaskOutput(result="done", elapsed_seconds=1.5)
|
|
assert out.result == "done"
|
|
assert out.error == ""
|
|
assert out.elapsed_seconds == 1.5
|
|
|
|
def test_error(self):
|
|
out = AgentTaskOutput(error="timeout", elapsed_seconds=3600.0)
|
|
assert out.error == "timeout"
|
|
assert out.result == ""
|
|
|
|
def test_serializes_to_dict(self):
|
|
out = AgentTaskOutput(result="ok", elapsed_seconds=0.1)
|
|
d = asdict(out)
|
|
assert d == {"result": "ok", "error": "", "elapsed_seconds": 0.1}
|
|
|
|
|
|
# -- workflow decorators -----------------------------------------------------
|
|
|
|
|
|
class TestWorkflowDecorators:
|
|
def test_agent_task_workflow_has_run(self):
|
|
assert hasattr(AgentTaskWorkflow, "run")
|
|
assert inspect.iscoroutinefunction(AgentTaskWorkflow.run)
|
|
|
|
def test_pipeline_workflow_has_run(self):
|
|
assert hasattr(PipelineWorkflow, "run")
|
|
assert inspect.iscoroutinefunction(PipelineWorkflow.run)
|
|
|
|
def test_fanout_workflow_has_run(self):
|
|
assert hasattr(FanOutWorkflow, "run")
|
|
assert inspect.iscoroutinefunction(FanOutWorkflow.run)
|
|
|
|
def test_workflow_classes_are_distinct(self):
|
|
assert AgentTaskWorkflow is not PipelineWorkflow
|
|
assert PipelineWorkflow is not FanOutWorkflow
|
|
|
|
|
|
# -- worker ------------------------------------------------------------------
|
|
|
|
|
|
class TestWorker:
|
|
def test_init_defaults(self):
|
|
w = Worker(client=None, queue="test-q")
|
|
assert w._queue == "test-q"
|
|
assert w._workflows == []
|
|
assert w._activities == []
|
|
assert w._worker is None
|
|
|
|
def test_register_workflow(self):
|
|
w = Worker(client=None)
|
|
|
|
class MyWorkflow:
|
|
pass
|
|
|
|
result = w.register_workflow(MyWorkflow)
|
|
assert result is MyWorkflow
|
|
assert MyWorkflow in w._workflows
|
|
|
|
def test_register_activity(self):
|
|
w = Worker(client=None)
|
|
|
|
async def my_activity(input):
|
|
return "ok"
|
|
|
|
result = w.register_activity(my_activity)
|
|
assert result is my_activity
|
|
assert my_activity in w._activities
|
|
|
|
def test_register_multiple(self):
|
|
w = Worker(client=None)
|
|
for i in range(5):
|
|
w.register_workflow(type(f"Wf{i}", (), {}))
|
|
assert len(w._workflows) == 5
|
|
|
|
def test_init_with_preloaded(self):
|
|
workflows = [AgentTaskWorkflow, PipelineWorkflow]
|
|
activities = [execute_agent_task]
|
|
w = Worker(client=None, workflows=workflows, activities=activities)
|
|
assert len(w._workflows) == 2
|
|
assert len(w._activities) == 1
|
|
|
|
def test_does_not_mutate_caller_list(self):
|
|
workflows: list = []
|
|
w = Worker(client=None, workflows=workflows)
|
|
w.register_workflow(AgentTaskWorkflow)
|
|
# The caller's original list should not be modified since we
|
|
# pass a new list via `or []`, but if caller passes a list,
|
|
# it IS the same reference. That's expected Python behavior.
|
|
# Just verify worker has the workflow.
|
|
assert AgentTaskWorkflow in w._workflows
|
|
|
|
|
|
# -- executor wiring ---------------------------------------------------------
|
|
|
|
|
|
class TestSetAgentExecutor:
|
|
def test_set_and_reset(self):
|
|
import hanzo_tasks.activities as act
|
|
|
|
original = act._agent_executor
|
|
|
|
async def my_exec(input):
|
|
return "executed"
|
|
|
|
set_agent_executor(my_exec)
|
|
assert act._agent_executor is my_exec
|
|
|
|
# Restore
|
|
act._agent_executor = original
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_without_executor_raises(self):
|
|
import hanzo_tasks.activities as act
|
|
|
|
saved = act._agent_executor
|
|
act._agent_executor = None
|
|
try:
|
|
with pytest.raises(RuntimeError, match="No agent executor registered"):
|
|
await execute_agent_task(None)
|
|
finally:
|
|
act._agent_executor = saved
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_with_executor(self):
|
|
import hanzo_tasks.activities as act
|
|
|
|
saved = act._agent_executor
|
|
|
|
async def mock_exec(input):
|
|
return AgentTaskOutput(result=f"done:{input.task_title}", elapsed_seconds=0.01)
|
|
|
|
set_agent_executor(mock_exec)
|
|
try:
|
|
inp = AgentTaskInput(
|
|
space_id="sp-1",
|
|
agent_id="ag-1",
|
|
task_title="test",
|
|
task_prompt="do it",
|
|
)
|
|
out = await execute_agent_task(inp)
|
|
assert out.result == "done:test"
|
|
assert out.elapsed_seconds == 0.01
|
|
finally:
|
|
act._agent_executor = saved
|
|
|
|
|
|
# -- workflow handle ---------------------------------------------------------
|
|
|
|
|
|
class TestWorkflowHandle:
|
|
def test_id(self):
|
|
class FakeHandle:
|
|
id = "wf-123"
|
|
result_run_id = "run-456"
|
|
|
|
h = WorkflowHandle(FakeHandle())
|
|
assert h.id == "wf-123"
|
|
assert h.run_id == "run-456"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel(self):
|
|
cancelled = False
|
|
|
|
class FakeHandle:
|
|
id = "wf-1"
|
|
result_run_id = "run-1"
|
|
|
|
async def cancel(self):
|
|
nonlocal cancelled
|
|
cancelled = True
|
|
|
|
h = WorkflowHandle(FakeHandle())
|
|
await h.cancel()
|
|
assert cancelled
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_signal(self):
|
|
signals = []
|
|
|
|
class FakeHandle:
|
|
id = "wf-1"
|
|
result_run_id = "run-1"
|
|
|
|
async def signal(self, name, data=None):
|
|
signals.append((name, data))
|
|
|
|
h = WorkflowHandle(FakeHandle())
|
|
await h.signal("pause", {"reason": "lunch"})
|
|
assert signals == [("pause", {"reason": "lunch"})]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_result(self):
|
|
class FakeHandle:
|
|
id = "wf-1"
|
|
result_run_id = "run-1"
|
|
|
|
async def result(self, result_type=None):
|
|
return "the-result"
|
|
|
|
h = WorkflowHandle(FakeHandle())
|
|
assert await h.result() == "the-result"
|
|
|
|
|
|
# -- __init__ exports --------------------------------------------------------
|
|
|
|
|
|
class TestExports:
|
|
def test_version(self):
|
|
import hanzo_tasks
|
|
|
|
assert hanzo_tasks.__version__ == "0.1.0"
|
|
|
|
def test_all_exports_importable(self):
|
|
import hanzo_tasks
|
|
|
|
for name in hanzo_tasks.__all__:
|
|
assert hasattr(hanzo_tasks, name), f"{name} not found in hanzo_tasks"
|