Files
agentci/tests/test_reducer.py
T
2026-07-22 23:10:23 +02:00

415 lines
12 KiB
Python

from dataclasses import FrozenInstanceError, replace
from pathlib import Path
import pytest
from agentci.engine.events import (
CommandReceived,
CommentLinked,
JobCompleted,
JobFailed,
JobProgress,
JobRejected,
JobStarted,
PermissionDenied,
PermissionGranted,
RuntimeSessionLinked,
ServiceRestarted,
WorkflowCreated,
WorkflowLinked,
)
from agentci.engine.model import (
Job,
JobKind,
JobStatus,
QueueName,
TaskKind,
TaskRequest,
Workflow,
WorkflowKind,
)
from agentci.engine.reducer import InvalidTransition, Transition, reduce_job, render_job_comment
def received(body: str = "/agent plan message", *, pr_number: int | None = None) -> Job:
return reduce_job(
None,
CommandReceived(
job_id="job",
delivery_id="delivery",
receive_sequence=1,
command_body=body,
target_key="org/repo:pr:8" if pr_number else "org/repo:issue:1",
repo_owner="org",
repo_name="repo",
issue_number=1,
pr_number=pr_number,
requester="alice",
comment_id=4,
),
).job
def queued(body: str = "/agent plan message", *, pr_number: int | None = None) -> Job:
state = received(body, pr_number=pr_number)
return reduce_job(state, PermissionGranted(job_id=state.id)).job
def running(*, workflow_id: str | None = None) -> Job:
state = queued()
state = reduce_job(state, JobStarted(job_id=state.id)).job
return replace(state, workflow_id=workflow_id)
def task_order(transition: Transition) -> list[tuple[TaskKind, QueueName]]:
return [(task.kind, task.queue) for task in transition.tasks]
def test_command_received_preserves_input_and_requests_authorization() -> None:
transition = reduce_job(
None,
CommandReceived(
job_id="job-7",
delivery_id="delivery-7",
receive_sequence=7,
command_body="/agent fix race",
target_key="org/repo:pr:8",
repo_owner="org",
repo_name="repo",
issue_number=1,
pr_number=8,
requester="alice",
comment_id=4,
),
)
assert transition == Transition(
Job(
id="job-7",
target_key="org/repo:pr:8",
repo_owner="org",
repo_name="repo",
issue_number=1,
pr_number=8,
requester="alice",
comment_id=4,
delivery_id="delivery-7",
receive_sequence=7,
command_body="/agent fix race",
),
(TaskRequest(TaskKind.AUTHORIZE, QueueName.CONTROL),),
)
@pytest.mark.parametrize(
("body", "pr_number", "expected_kind", "expected_message"),
[
("/agent plan write tests", None, JobKind.PLAN, "write tests"),
("/agent iterate refine", None, JobKind.ITERATE_PLAN, "refine"),
("/agent iterate address review", 8, JobKind.ITERATE_IMPLEMENT, "address review"),
],
)
def test_permission_granted_queues_the_resolved_command(
body: str,
pr_number: int | None,
expected_kind: JobKind,
expected_message: str,
) -> None:
transition = reduce_job(
received(body, pr_number=pr_number),
PermissionGranted(job_id="job"),
)
assert (
transition.job.status,
transition.job.stage,
transition.job.kind,
transition.job.message,
task_order(transition),
) == (
JobStatus.QUEUED,
"queued",
expected_kind,
expected_message,
[
(TaskKind.EXECUTE, QueueName.JOBS),
(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL),
],
)
@pytest.mark.parametrize(
("event", "expected_error"),
[
(PermissionDenied(job_id="job"), "repository write permission is required"),
(PermissionGranted(job_id="job"), "Unknown agent command"),
],
)
def test_permission_rejection_is_terminal_and_reconciled(
event: PermissionDenied | PermissionGranted,
expected_error: str,
) -> None:
state = received("/agent nonsense") if isinstance(event, PermissionGranted) else received()
transition = reduce_job(state, event)
assert (
transition.job.status,
transition.job.stage,
expected_error in (transition.job.error or ""),
task_order(transition),
) == (
JobStatus.REJECTED,
"rejected",
True,
[(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)],
)
def test_start_moves_a_queued_job_to_running_and_reconciles() -> None:
transition = reduce_job(queued(), JobStarted(job_id="job"))
assert (
transition.job.status,
transition.job.stage,
task_order(transition),
) == (
JobStatus.RUNNING,
"starting",
[(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)],
)
@pytest.mark.parametrize(
("event", "changes"),
[
(JobProgress(job_id="job", stage="cloning"), {"stage": "cloning"}),
(
WorkflowCreated(
job_id="job",
workflow=Workflow(
id="created-workflow",
kind=WorkflowKind.PLAN,
repo_owner="org",
repo_name="repo",
issue_number=1,
workspace_path=Path("/work/repo"),
base_sha="abc",
),
stage="planning",
),
{"workflow_id": "created-workflow", "stage": "planning"},
),
(
WorkflowLinked(job_id="job", workflow_id="linked-workflow", stage="discussing"),
{"workflow_id": "linked-workflow", "stage": "discussing"},
),
(
RuntimeSessionLinked(job_id="job", session_id="session-1"),
{"runtime_session_id": "session-1"},
),
],
)
def test_running_progress_and_links_update_only_reported_fields(
event: JobProgress | WorkflowCreated | WorkflowLinked | RuntimeSessionLinked,
changes: dict[str, object],
) -> None:
state = running()
transition = reduce_job(state, event)
assert transition == Transition(replace(state, **changes))
def test_completion_persists_the_result_and_reconciles() -> None:
transition = reduce_job(running(), JobCompleted(job_id="job", comment_body="# Result"))
assert (
transition.job.status,
transition.job.stage,
transition.job.comment_body,
task_order(transition),
) == (
JobStatus.SUCCEEDED,
"completed",
"# Result",
[(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)],
)
@pytest.mark.parametrize(
("event", "workflow_id", "expected_status", "expected_stage", "expected_error", "tasks"),
[
(
JobFailed(job_id="job", stage="testing", error="tests failed"),
None,
JobStatus.FAILED,
"testing",
"tests failed",
[(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL)],
),
(
JobFailed(job_id="job", stage="testing", error="tests failed"),
"workflow",
JobStatus.FAILED,
"testing",
"tests failed",
[
(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL),
(TaskKind.FAIL_WORKFLOW, QueueName.CONTROL),
],
),
(
JobRejected(job_id="job", reason="no plan exists"),
"workflow",
JobStatus.REJECTED,
"rejected",
"no plan exists",
[
(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL),
(TaskKind.FAIL_WORKFLOW, QueueName.CONTROL),
],
),
],
)
def test_running_failure_and_rejection_are_terminal_with_ordered_cleanup(
event: JobFailed | JobRejected,
workflow_id: str | None,
expected_status: JobStatus,
expected_stage: str,
expected_error: str,
tasks: list[tuple[TaskKind, QueueName]],
) -> None:
transition = reduce_job(running(workflow_id=workflow_id), event)
assert (
transition.job.status,
transition.job.stage,
transition.job.error,
task_order(transition),
) == (expected_status, expected_stage, expected_error, tasks)
@pytest.mark.parametrize(
("workflow_id", "tasks"),
[
(
None,
[
(TaskKind.ABORT_SESSIONS, QueueName.CONTROL),
(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL),
],
),
(
"workflow",
[
(TaskKind.ABORT_SESSIONS, QueueName.CONTROL),
(TaskKind.FAIL_WORKFLOW, QueueName.CONTROL),
(TaskKind.RECONCILE_COMMENT, QueueName.CONTROL),
],
),
],
)
def test_restart_fails_a_running_job_with_ordered_cleanup(
workflow_id: str | None,
tasks: list[tuple[TaskKind, QueueName]],
) -> None:
transition = reduce_job(running(workflow_id=workflow_id), ServiceRestarted(job_id="job"))
assert (
transition.job.status,
transition.job.stage,
transition.job.error,
task_order(transition),
) == (
JobStatus.FAILED,
"interrupted",
"Service restarted during an active OpenCode turn",
tasks,
)
def test_restart_is_a_noop_after_a_job_is_terminal() -> None:
completed = reduce_job(running(), JobCompleted(job_id="job", comment_body="ok")).job
assert reduce_job(completed, ServiceRestarted(job_id="job")) == Transition(completed)
def test_comment_link_is_allowed_after_a_job_is_terminal() -> None:
completed = reduce_job(running(), JobCompleted(job_id="job", comment_body="ok")).job
assert reduce_job(completed, CommentLinked(job_id="job", comment_id=9)).job == replace(
completed, accepted_comment_id=9
)
@pytest.mark.parametrize(
("state", "event"),
[
(received(), JobStarted(job_id="job")),
(queued(), PermissionGranted(job_id="job")),
(running(), PermissionGranted(job_id="job")),
(
reduce_job(running(), JobCompleted(job_id="job", comment_body="ok")).job,
JobStarted(job_id="job"),
),
],
ids=["received", "queued", "running", "terminal"],
)
def test_events_invalid_for_the_current_status_are_rejected(
state: Job,
event: JobStarted | PermissionGranted,
) -> None:
with pytest.raises(InvalidTransition, match="invalid while job"):
reduce_job(state, event)
def test_only_command_received_can_create_state() -> None:
with pytest.raises(InvalidTransition, match="Only CommandReceived"):
reduce_job(None, PermissionGranted(job_id="job"))
def test_event_job_id_must_match_state() -> None:
with pytest.raises(InvalidTransition, match="job ID does not match"):
reduce_job(received(), PermissionDenied(job_id="another-job"))
@pytest.mark.parametrize(
("state", "expected"),
[
(
received(),
"<!-- agentci:job id=job -->\nAgent job `job` received (`command`; stage: `received`).",
),
(
queued(),
"<!-- agentci:job id=job -->\nAgent job `job` queued (`plan`; stage: `queued`).",
),
(
replace(
running(),
status=JobStatus.SUCCEEDED,
stage="completed",
comment_body="# Done",
),
"<!-- agentci:job id=job -->\n# Done",
),
(
replace(received(), status=JobStatus.REJECTED, stage="rejected", error="not allowed"),
"<!-- agentci:job id=job -->\nAgent job `job` was rejected: not allowed",
),
(
replace(running(), status=JobStatus.FAILED, stage="testing", error="failed"),
"<!-- agentci:job id=job -->\nAgent job `job` failed during `testing`: failed",
),
],
ids=["received", "queued", "succeeded", "rejected", "failed"],
)
def test_render_comment_describes_each_job_outcome(state: Job, expected: str) -> None:
assert render_job_comment(state) == expected
def test_job_state_is_immutable() -> None:
state = received()
with pytest.raises(FrozenInstanceError):
state.stage = "changed" # type: ignore[misc]