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(), "\nAgent job `job` received (`command`; stage: `received`).", ), ( queued(), "\nAgent job `job` queued (`plan`; stage: `queued`).", ), ( replace( running(), status=JobStatus.SUCCEEDED, stage="completed", comment_body="# Done", ), "\n# Done", ), ( replace(received(), status=JobStatus.REJECTED, stage="rejected", error="not allowed"), "\nAgent job `job` was rejected: not allowed", ), ( replace(running(), status=JobStatus.FAILED, stage="testing", error="failed"), "\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]