test/openagents/issues/work_scope_test.exs

main at 58e6347eeb72 · 7 KB

defmodule OpenAgents.Issues.WorkScopeTest do
  @moduledoc """
  `#10`: starting bounded agent work *from* an issue.

  The property under test is that the bound is the issue's, not the caller's.
  Two halves:

  **The scope is read from the issue.** The objective is written from the
  issue's own title and body, and the branch from its number, so two attempts
  on the same issue are asked the same question.

  **The limits are read from the issue, and a caller may only narrow them.** An
  issue that states every section `OUTCOME-001` requires buys the full hour; an
  issue that does not buys a short exploratory window, because a claim against
  it can never be accepted. `Assignments.create/1` is where that applies, so it
  applies to the issue page and to the API alike rather than to whichever
  surface remembered.
  """
  use OpenAgents.DataCase, async: true

  import Ecto.Query
  import OpenAgents.AccountsFixtures

  alias OpenAgents.Box.ConversationBox
  alias OpenAgents.Conversations
  alias OpenAgents.Forge.{Assignment, Assignments}
  alias OpenAgents.Issues
  alias OpenAgents.Issues.WorkScope
  alias OpenAgents.Repo

  @scoped_body """
  ## Problem

  Nothing bounds an attempt.

  ## Scope

  One module and its admission point.

  ## Acceptance criteria

  - The wall clock comes from the issue.

  ## Success metrics

  An unscoped issue cannot buy an hour.
  """

  setup do
    owner = repository_user_fixture("scope-owner")
    repository = repository_with_member_fixture(owner, %{visibility: "public"}, "owner")

    {:ok, scoped} =
      Issues.create_issue(repository, %{title: "Bound the work", body: @scoped_body})

    {:ok, unscoped} =
      Issues.create_issue(repository, %{title: "Do something", body: "Make it better."})

    %{owner: owner, repository: repository, scoped: scoped, unscoped: unscoped}
  end

  describe "the scope comes from the issue" do
    test "the objective names the issue, its title, and its one writable branch", context do
      objective = WorkScope.objective(context.scoped)

      assert objective =~ "issue ##{context.scoped.number}"
      assert objective =~ "Bound the work"
      assert objective =~ "Nothing bounds an attempt."
      assert objective =~ "agent/issue-#{context.scoped.number}"
    end

    test "the branch is derived from the issue number", context do
      assert WorkScope.branch(context.scoped) == "agent/issue-#{context.scoped.number}"
    end

    test "a long body is clamped on a character boundary, not refused", context do
      body = String.duplicate("é", 8_000)
      {:ok, long} = Issues.create_issue(context.repository, %{title: "Long", body: body})

      objective = WorkScope.objective(long)

      assert String.valid?(objective)
      assert byte_size(objective) < 8_000
    end
  end

  describe "the limits come from the issue's own scope" do
    test "an issue stating every required section buys the full hour", context do
      scope = WorkScope.for_issue(context.scoped)

      assert scope.scoped?
      assert scope.missing_sections == []
      assert scope.wall_clock_ms == WorkScope.scoped_wall_clock_ms()
    end

    test "an issue missing a section buys the exploratory window", context do
      scope = WorkScope.for_issue(context.unscoped)

      refute scope.scoped?
      assert scope.wall_clock_ms == WorkScope.unscoped_wall_clock_ms()
      assert scope.wall_clock_ms < WorkScope.scoped_wall_clock_ms()
    end

    test "the missing sections are named, in contract order", context do
      assert WorkScope.missing_sections(context.unscoped) ==
               [:problem, :scope, :acceptance_criteria, :success_metrics]
    end

    test "a body absent entirely states no section", context do
      {:ok, bodiless} = Issues.create_issue(context.repository, %{title: "No body"})

      refute WorkScope.scoped?(bodiless)
    end

    test "a heading with no content under it does not count as stated", context do
      {:ok, empty} =
        Issues.create_issue(context.repository, %{
          title: "Headings only",
          body: "## Problem\n\n## Scope\n\n## Acceptance criteria\n\n## Success metrics\n"
        })

      assert WorkScope.missing_sections(empty) ==
               [:problem, :scope, :acceptance_criteria, :success_metrics]
    end

    test "the reader is the grader's own parser, so the two cannot disagree", context do
      stated = OpenAgents.Issues.CompletionClaims.sections(context.scoped.body)

      for section <- OpenAgents.AcceptedOutcome.required_issue_sections() do
        assert Map.get(stated, section), "the grader does not see #{section}"
        refute section in WorkScope.missing_sections(context.scoped)
      end
    end
  end

  describe "the admission point applies the bound" do
    test "an unscoped issue's attempt gets the short deadline", context do
      assignment = admit(context, context.unscoped)

      assert within_ms?(assignment.deadline_at, WorkScope.unscoped_wall_clock_ms())
    end

    test "a scoped issue's attempt gets the full deadline", context do
      assignment = admit(context, context.scoped)

      assert within_ms?(assignment.deadline_at, WorkScope.scoped_wall_clock_ms())
    end

    test "a caller may narrow the bound", context do
      requested = DateTime.add(DateTime.utc_now(), 120, :second)
      assignment = admit(context, context.scoped, %{"deadline_at" => requested})

      assert DateTime.compare(assignment.deadline_at, requested) == :eq
    end

    test "a caller may not widen the bound", context do
      # Two hours, past both the issue's window and the deployment TTL.
      requested = DateTime.add(DateTime.utc_now(), 7_200, :second)
      assignment = admit(context, context.unscoped, %{"deadline_at" => requested})

      assert DateTime.compare(assignment.deadline_at, requested) == :lt
      assert within_ms?(assignment.deadline_at, WorkScope.unscoped_wall_clock_ms())
    end
  end

  # The run never starts in a test, and it does not need to: the assignment is
  # committed by `persist_assignment/7` before `start_target/7` is reached, so
  # the row this reads is the row the production path writes.
  defp admit(context, issue, extra \\ %{}) do
    {:ok, conversation} = Conversations.ensure_conversation(context.owner)

    {:ok, box} =
      %ConversationBox{}
      |> ConversationBox.changeset(%{
        conversation_id: conversation.id,
        box_id: "bx_scope_#{System.unique_integer([:positive])}",
        state: "ready",
        setup_status: "done"
      })
      |> Repo.insert()

    attrs =
      Map.merge(
        %{
          "target_kind" => "box",
          "box_id" => box.box_id,
          "conversation_id" => conversation.id,
          "repository_id" => context.repository.id,
          "issue_number" => issue.number,
          "branch" => WorkScope.branch(issue),
          "requesting_user" => context.owner,
          "requesting_principal" => context.owner
        },
        extra
      )

    result = try_create(attrs)

    case Repo.one(from a in Assignment, where: a.issue_id == ^issue.id, limit: 1) do
      nil -> flunk("no assignment persisted: #{inspect(result)}")
      row -> row
    end
  end

  defp try_create(attrs) do
    Assignments.create(attrs)
  rescue
    error -> {:error, error}
  end

  # The deadline is stamped at admission, so it lands within a second of
  # `now + bound`. Asserting a window rather than an instant keeps the test
  # about the bound rather than about the clock.
  defp within_ms?(deadline, bound) do
    actual = DateTime.diff(deadline, DateTime.utc_now(), :millisecond)
    actual > bound - 5_000 and actual <= bound
  end
end