Files
zeekay 7a5eba2dad feat(hanzo-tasks): Python SDK for durable task execution
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
2026-03-19 10:29:31 -07:00

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"