415 lines
12 KiB
Python
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]
|