test/openagents/tools/issue_capture_test.exs

main at 58e6347eeb72 · 10 KB

defmodule OpenAgents.Tools.IssueCaptureTest do
  @moduledoc """
  The `capture_issue` tool, driven through `OpenAgents.Tools.Runner` (#77).

  The domain rules are proven in `OpenAgents.Issues.CaptureTest`. What this
  file proves is the part only the tool has: that the ask-every-time gate is
  real, that a caller's own membership is what files the issue, and that the
  typed refusals a person reads say which authority is missing.
  """

  use OpenAgents.DataCase, async: false

  import OpenAgents.AccountsFixtures

  alias OpenAgents.{Accounts, Conversations, Issues, Repositories}
  alias OpenAgents.Tools.{AdmittedCatalog, ConversationExecutionContext, Registry, Runner}

  @modules [OpenAgents.Tools.IssueCapture, OpenAgents.Tools.ModuleDiscover]

  setup do
    {:ok, snapshot} = Registry.build(@modules)
    scope = signed_in_scope("issue-capture")
    repository = repository_with_member_fixture(scope.user, %{}, "maintainer")

    %{snapshot: snapshot, scope: scope, repository: repository}
  end

  describe "filing for an authorized caller" do
    test "creates the issue and returns its number and URL", context do
      assert {:ok, outcome} =
               capture(context, %{repository: path(context), problem: "Add CSV export."})

      assert outcome["status"] == "succeeded"

      result = outcome["result"]
      assert result["schema"] == "openagents.captured_issue.v1"
      assert result["outcome"] == "created"
      assert result["title"] == "Add CSV export"
      assert result["state"] == "open"
      assert result["number"] > 0
      assert result["repository"] == path(context)
      assert result["url"] =~ "/#{path(context)}/issues/#{result["number"]}"

      issue = Issues.get_issue_by_number!(context.repository, result["number"])
      assert issue.author_user_id == context.scope.user.id
    end

    # TOOL-004: an external effect must name what it affected.
    test "the outcome names the issue it created", context do
      assert {:ok, outcome} =
               capture(context, %{repository: path(context), problem: "Add CSV export."})

      assert [reference] = outcome["target_receipt_refs"]
      assert reference =~ "forge-issue:"
    end

    test "a repeat returns the first issue rather than filing a second", context do
      assert {:ok, first} =
               capture(context, %{repository: path(context), problem: "Add CSV export."})

      assert {:ok, second} =
               capture(context, %{repository: path(context), problem: "Add CSV export."})

      assert first["result"]["outcome"] == "created"
      assert second["result"]["outcome"] == "existing"
      assert second["result"]["number"] == first["result"]["number"]
      assert length(Issues.list_issues(context.repository, state: "all")) == 1
    end
  end

  describe "refusing a caller without write access" do
    test "names the missing role instead of failing vaguely", context do
      stranger = signed_in_scope("issue-capture-stranger")
      other = repository_with_member_fixture(stranger.user, %{}, "owner")
      {:ok, _membership} = Repositories.add_member(other, context.scope.user, "viewer")

      assert {:ok, outcome} =
               capture(context, %{
                 repository: other.owner <> "/" <> other.name,
                 problem: "Add CSV export."
               })

      assert outcome["status"] == "refused"
      assert outcome["error"]["code"] == "repository_write_access_required"
      assert outcome["error"]["message"] =~ "write access"
      assert outcome["error"]["message"] =~ "owner, maintainer, or contributor"
      refute outcome["error"]["message"] == "The tool call failed validation or execution."

      assert Issues.list_issues(other, state: "all") == []
    end

    # A refusal must not double as an existence oracle for a private repository.
    test "a repository the caller cannot see is absent, not merely unwritable", context do
      stranger = signed_in_scope("issue-capture-private")
      private = repository_with_member_fixture(stranger.user, %{visibility: "private"}, "owner")

      assert {:ok, outcome} =
               capture(context, %{
                 repository: private.owner <> "/" <> private.name,
                 problem: "Add CSV export."
               })

      # `repository_not_found` classifies as `failed` rather than `refused`,
      # the same as it does for the read-only repository tools: the caller is
      # not being told they lack authority, they are being told there is
      # nothing there. Keeping the two apart is the point of the test.
      refute outcome["status"] == "succeeded"
      assert outcome["error"]["code"] == "repository_not_found"
    end

    test "a conversation that resolves to no account files nothing", %{snapshot: snapshot} do
      assert {:ok, outcome} =
               Runner.run(
                 snapshot,
                 call(%{repository: "OpenAgentsInc/openagents.com", problem: "Add CSV export."}),
                 unbound_context(snapshot)
               )

      refute outcome["status"] == "succeeded"
      assert outcome["error"]["code"] == "repository_authentication_required"
    end
  end

  describe "the ask-every-time gate" do
    # The whole basis on which a writing tool is admitted to the shipped
    # catalog at all (TOOL-006). If this stops holding, `capture_issue` becomes
    # a tool the model can file public issues with unasked.
    test "without a current receipt the tool refuses", context do
      assert {:ok, outcome} =
               Runner.run(
                 context.snapshot,
                 call(%{repository: path(context), problem: "Add CSV export."}),
                 consentless_context(context)
               )

      assert outcome["status"] == "refused"
      assert outcome["error"]["code"] == "module_approval_required"
      assert Issues.list_issues(context.repository, state: "all") == []
    end

    test "without a current receipt the tool is not even offered", context do
      offered = offered(context.snapshot, consentless_context(context))

      refute "capture_issue" in offered
      assert "module_discover" in offered
    end

    test "with a current receipt it is offered", context do
      assert "capture_issue" in offered(context.snapshot, consenting_context(context))
    end

    # Consent is scoped to one conversation. A receipt minted elsewhere is not
    # a standing grant this conversation can spend.
    test "a receipt for another conversation does not carry over", context do
      elsewhere = %{
        consentless_context(context)
        | approval_receipts: [receipt("conversation:#{Ecto.UUID.generate()}")]
      }

      assert {:ok, outcome} =
               Runner.run(
                 context.snapshot,
                 call(%{repository: path(context), problem: "Add CSV export."}),
                 elsewhere
               )

      assert outcome["status"] == "refused"
      assert outcome["error"]["code"] == "module_approval_required"
    end
  end

  describe "the declaration" do
    test "says it writes, and says when not to call it", %{snapshot: snapshot} do
      tool = Map.fetch!(snapshot.tools, "capture_issue")

      assert tool.side_effect == :external_effect
      assert tool.required_authority == "repository.write"
      assert tool.reach == [:signed_in_owner]
      assert tool.module_metadata["approval_class"] == "external_confirmation"
      assert tool.module_metadata["facets"]["approval_enforcement"] == "host_receipt"

      # Re-admission criterion 5: a description that only names a capability
      # makes the model try the tool and read the refusal.
      assert tool.description =~ "Do not call"
      assert tool.description =~ "Do not guess the repository"

      # The authority a conversation caller actually holds.
      assert MapSet.member?(ConversationExecutionContext.authorities(), tool.required_authority)
    end
  end

  defp capture(context, arguments) do
    Runner.run(context.snapshot, call(arguments), consenting_context(context))
  end

  defp call(arguments) do
    %{
      call_id: "call-#{System.unique_integer([:positive])}",
      name: "capture_issue",
      version: 1,
      raw_arguments: JSON.encode!(arguments)
    }
  end

  defp path(%{repository: repository}), do: repository.owner <> "/" <> repository.name

  defp offered(snapshot, context) do
    snapshot
    |> AdmittedCatalog.provider_definitions(context, "file this request as an issue", top_k: 64)
    |> Enum.map(& &1.name)
  end

  defp consentless_context(%{scope: scope, snapshot: snapshot}) do
    ConversationExecutionContext.build(%{
      surface: "text",
      conversation_id: scope.conversation.id,
      owner_visitor_id: scope.owner.id,
      owner_user_id: scope.owner.user_id,
      module_registry_snapshot: snapshot
    })
  end

  defp consenting_context(context) do
    consentless = consentless_context(context)
    %{consentless | approval_receipts: [receipt(consentless.scope_ref)]}
  end

  # What a consent surface mints when the person confirms this one filing:
  # bound to the module, the version, and this conversation, and explicit.
  defp receipt(scope_ref) do
    %{
      "schema" => "sarah.module_approval.v1",
      "approval_class" => "external_confirmation",
      "module_id" => "sarah.tool.issue_capture.v1",
      "version" => 1,
      "scope_ref" => scope_ref,
      "explicit" => true,
      "actor_type" => "person",
      "receipt_ref" => "issue-capture-consent:#{System.unique_integer([:positive])}"
    }
  end

  defp unbound_context(snapshot) do
    %OpenAgents.Tools.ExecutionContext{
      scope: "browser_conversation",
      scope_ref: "conversation:unbound",
      authorities: ConversationExecutionContext.authorities(),
      approval_receipts: [receipt("conversation:unbound")],
      surface: "text",
      module_registry_snapshot: snapshot
    }
  end

  defp signed_in_scope(login) do
    {:ok, user} =
      Accounts.upsert_github_user(%{
        github_id: System.unique_integer([:positive]),
        github_login: "#{login}-#{System.unique_integer([:positive])}",
        github_avatar_url: "https://avatars.githubusercontent.com/u/1?v=4"
      })

    {:ok, conversation} = Conversations.ensure_conversation(user)
    owner = Conversations.get_conversation_owner!(conversation)

    %{user: user, owner: owner, conversation: conversation}
  end
end