test/openagents/incidents_test.exs

main at 58e6347eeb72 · 7 KB

defmodule OpenAgents.IncidentsTest do
  use OpenAgents.DataCase
  alias OpenAgents.Incidents
  alias OpenAgents.Incidents.Triage
  alias OpenAgents.{Accounts, Conversations, Repo}

  describe "Triage.classify/1" do
    test "maps expected, degraded, and unknown codes to tiers" do
      assert Triage.classify("cancelled") == "expected"
      assert Triage.classify("machine_offline") == "expected"
      assert Triage.classify("provider_timeout") == "degraded"
      assert Triage.classify("turn_timeout") == "degraded"
      assert Triage.classify("delegation_timeout") == "degraded"
      assert Triage.classify("delegation_refused") == "expected"
      assert Triage.classify("delegation_cancelled") == "expected"
      assert Triage.classify("delegation_failed") == "anomalous"
      assert Triage.classify("task_exit") == "degraded"
      assert Triage.classify("task_exit:RuntimeError") == "degraded"
      assert Triage.classify("transport") == "degraded"
      assert Triage.classify("transport:provider_task_exited") == "degraded"
      assert Triage.classify("provider_task_exited") == "degraded"
      # Unrecognized families stay anomalous — the whole point.
      assert Triage.classify("something_we_never_saw") == "anomalous"
      assert Triage.classify(nil) == "anomalous"
    end

    test "classifies on the family head of a family:detail code" do
      assert Triage.classify("provider_timeout:read") == "degraded"
      assert Triage.classify("cancelled:by_user") == "expected"
    end
  end

  describe "record/1" do
    test "classifies severity from the code and stores an owner-scoped incident" do
      scope = owner_scope("incident-record")

      assert {:ok, incident} =
               Incidents.record(%{
                 conversation_id: scope.conversation.id,
                 owner_user_id: scope.user.id,
                 owner_visitor_id: scope.owner.id,
                 surface: "text",
                 origin: "turn_server",
                 correlation_ref: "turn-1",
                 code: "transport:provider_task_exited",
                 summary: "Text turn failed"
               })

      assert incident.severity == "degraded"
      assert incident.status == "open"
      assert incident.owner_user_id == scope.user.id
    end

    test "defaults a missing code to task_exit, never unknown" do
      scope = owner_scope("incident-default-code")

      assert {:ok, incident} =
               Incidents.record(%{
                 conversation_id: scope.conversation.id,
                 owner_user_id: scope.user.id,
                 owner_visitor_id: scope.owner.id,
                 surface: "text",
                 origin: "turn_server"
               })

      assert incident.code == "task_exit"
      assert incident.severity == "degraded"
      refute incident.code == "unknown"
    end

    test "scrubs credential material and bounds oversize context" do
      scope = owner_scope("incident-scrub")
      # A value that reads as a card number must never be stored verbatim.
      secret = "4111 1111 1111 1111"

      assert {:ok, incident} =
               Incidents.record(%{
                 conversation_id: scope.conversation.id,
                 owner_user_id: scope.user.id,
                 owner_visitor_id: scope.owner.id,
                 surface: "text",
                 origin: "turn_server",
                 code: "transport",
                 context: %{"note" => secret, "tool" => "computer_agent"}
               })

      refute incident.context["note"] == secret
      assert incident.context["tool"] == "computer_agent"
    end

    test "a long context value is truncated so nothing unbounded is stored" do
      scope = owner_scope("incident-bound")
      huge = String.duplicate("x", 20_000)

      assert {:ok, incident} =
               Incidents.record(%{
                 conversation_id: scope.conversation.id,
                 owner_user_id: scope.user.id,
                 owner_visitor_id: scope.owner.id,
                 surface: "text",
                 origin: "turn_server",
                 code: "transport",
                 context: %{"blob" => huge}
               })

      assert String.length(incident.context["blob"]) <= 500
    end

    test "a context that overflows even after per-value bounding gets a marker" do
      scope = owner_scope("incident-overflow")
      # Many keys, each bounded to 500 chars, sum past the 8 KB cap.
      context = for n <- 1..40, into: %{}, do: {"k#{n}", String.duplicate("y", 500)}

      assert {:ok, incident} =
               Incidents.record(%{
                 conversation_id: scope.conversation.id,
                 owner_user_id: scope.user.id,
                 owner_visitor_id: scope.owner.id,
                 surface: "text",
                 origin: "turn_server",
                 code: "transport",
                 context: context
               })

      assert incident.context["truncated"] == true
    end
  end

  describe "list_recent/2 and recurrence_count/2" do
    test "scopes to the owner and this conversation, newest first" do
      scope = owner_scope("incident-list")
      other = owner_scope("incident-list-other")

      for code <- ["a", "b", "c"] do
        {:ok, _} = record(scope, code)
      end

      {:ok, _foreign} = record(other, "z")

      recent = Incidents.list_recent(scope.user.id, conversation_id: scope.conversation.id)
      assert length(recent) == 3
      # Never another owner's incident.
      refute Enum.any?(recent, &(&1.code == "z"))
    end

    test "counts recurrences of a code for an owner" do
      scope = owner_scope("incident-recur")
      {:ok, _} = record(scope, "transport")
      {:ok, _} = record(scope, "transport")
      {:ok, _} = record(scope, "other")

      assert Incidents.recurrence_count(scope.user.id, "transport") == 2
      assert Incidents.recurrence_count(scope.user.id, "other") == 1
    end
  end

  describe "report/1" do
    test "records and, with the fixer disabled, never spawns a job" do
      scope = owner_scope("incident-report")

      assert {:ok, incident} =
               Incidents.report(%{
                 conversation_id: scope.conversation.id,
                 owner_user_id: scope.user.id,
                 owner_visitor_id: scope.owner.id,
                 surface: "text",
                 origin: "turn_server",
                 code: "transport:provider_task_exited"
               })

      assert incident.severity == "degraded"
      # Fixer is off by default: the incident is recorded but not being fixed.
      assert is_nil(incident.fixer_job_id)
    end
  end

  defp record(scope, code) do
    Incidents.record(%{
      conversation_id: scope.conversation.id,
      owner_user_id: scope.user.id,
      owner_visitor_id: scope.owner.id,
      surface: "text",
      origin: "turn_server",
      code: code
    })
  end

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

    {:ok, conversation} = Conversations.ensure_conversation(user)
    owner = Repo.get!(OpenAgents.Conversations.Visitor, conversation.visitor_id)
    %{user: user, owner: owner, conversation: conversation}
  end
end