rewrite phase 1
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
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]
|
||||
Reference in New Issue
Block a user