test/openagents_web/controllers/responses_controller_test.exs

58e6347eeb72 · 17 KB

defmodule OpenAgentsWeb.ResponsesControllerTest do
  use OpenAgentsWeb.ConnCase, async: false

  alias OpenAgents.Memories

  alias OpenAgents.Providers.{
    FailingTestProvider,
    RecordingTestProvider,
    ToolCallingTestProvider,
    UnconfiguredTestProvider
  }

  # The default model rides the Vercel gateway lane; swapping the lane's
  # adapter is how a test decides what "real inference" answers with.
  defp swap_lane(adapter) do
    previous = Application.get_env(:openagents, :vercel_gateway_provider)
    Application.put_env(:openagents, :vercel_gateway_provider, adapter)
    on_exit(fn -> Application.put_env(:openagents, :vercel_gateway_provider, previous) end)
  end

  describe "the non-streaming response object" do
    setup do
      swap_lane(RecordingTestProvider)
      :ok
    end

    test "answers an anonymous caller from the provider", %{conn: conn} do
      conn = post(conn, ~p"/api/v1/responses", %{input: "hello"})

      assert %{
               "object" => "response",
               "status" => "completed",
               "model" => "gemini-3.7-flash",
               "output" => [message],
               "usage" => %{"input_tokens" => 4, "output_tokens" => 8, "total_tokens" => 12}
             } = json_response(conn, 200)

      assert %{
               "type" => "message",
               "role" => "assistant",
               "status" => "completed",
               "content" => [%{"type" => "output_text", "text" => "Recorded."}]
             } = message
    end

    test "carries the caller's instructions and input items to the provider", %{conn: conn} do
      Application.put_env(:openagents, :test_recording_provider_observer, self())
      on_exit(fn -> Application.delete_env(:openagents, :test_recording_provider_observer) end)

      conn =
        post(conn, ~p"/api/v1/responses", %{
          instructions: "Answer in French.",
          input: [
            %{role: "user", content: [%{type: "input_text", text: "bonjour"}]},
            %{role: "assistant", content: "salut"},
            %{role: "user", content: "encore"}
          ],
          max_output_tokens: 128
        })

      assert json_response(conn, 200)
      assert_receive {:recorded_request, _id, request}
      assert request.instructions == "Answer in French."
      assert request.max_output == 128

      assert request.input == [
               %{role: "user", content: "bonjour"},
               %{role: "assistant", content: "salut"},
               %{role: "user", content: "encore"}
             ]
    end

    test "reports a provider failure as a failed response object", %{conn: conn} do
      swap_lane(FailingTestProvider)

      conn = post(conn, ~p"/api/v1/responses", %{input: "hello"})

      assert %{"status" => "failed", "error" => %{"code" => "provider_failed"}} =
               json_response(conn, 200)
    end
  end

  describe "streaming" do
    setup do
      swap_lane(RecordingTestProvider)
      :ok
    end

    test "streams the semantic event sequence around the provider's deltas", %{conn: conn} do
      conn = post(conn, ~p"/api/v1/responses", %{input: "hello", stream: true})

      assert [type] = get_resp_header(conn, "content-type")
      assert type =~ "text/event-stream"
      body = response(conn, 200)

      for {event, at} <- Enum.with_index(~w(
            response.created
            response.output_item.added
            response.content_part.added
            response.output_text.delta
            response.output_text.done
            response.content_part.done
            response.output_item.done
            response.completed
          )) do
        assert body =~ "event: " <> event
        assert body =~ ~s("sequence_number":#{at})
      end

      assert body =~ ~s("delta":"Recorded.")
      assert body =~ ~s("text":"Recorded.")
      assert body =~ ~s("input_tokens":4)
      refute body =~ "Acknowledged"
    end

    test "a provider failure mid-stream arrives as response.failed", %{conn: conn} do
      swap_lane(FailingTestProvider)

      conn = post(conn, ~p"/api/v1/responses", %{input: "hello", stream: true})
      body = response(conn, 200)

      assert body =~ ~s("delta":"half an ")
      assert body =~ "event: response.failed"
      assert body =~ ~s("code":"provider_failed")
      refute body =~ "response.completed"
    end
  end

  describe "refusals, in the envelope" do
    test "a request with no input", %{conn: conn} do
      swap_lane(RecordingTestProvider)
      conn = post(conn, ~p"/api/v1/responses", %{})

      body = json_response(conn, 422)
      assert body["code"] == "validation_failed"
      assert body["errors"] == %{"input" => ["is required"]}
    end

    test "a model outside the catalog", %{conn: conn} do
      swap_lane(RecordingTestProvider)
      conn = post(conn, ~p"/api/v1/responses", %{input: "hi", model: "gpt-9-imaginary"})

      body = json_response(conn, 422)
      assert body["errors"]["model"] == ["`gpt-9-imaginary` is not in the catalog"]
    end

    test "a lane with no configured credential", %{conn: conn} do
      swap_lane(UnconfiguredTestProvider)
      conn = post(conn, ~p"/api/v1/responses", %{input: "hi"})

      body = json_response(conn, 503)
      assert body["code"] == "model_unavailable"
    end
  end

  # Server-side recall (#51). The point of putting recall here is that no
  # client implements it, so what these prove is the seam: a recognized account
  # gets its memories in the model context, an unrecognized caller gets exactly
  # what it got before, and the attachment is bounded in both directions.
  describe "recall for a recognized account" do
    setup do
      swap_lane(RecordingTestProvider)
      Application.put_env(:openagents, :test_recording_provider_observer, self())
      on_exit(fn -> Application.delete_env(:openagents, :test_recording_provider_observer) end)
      :ok
    end

    test "attaches the account's memory as a bounded note", %{conn: conn} do
      conn = put_chat_api_token(conn, "responses-recall")
      user = github_user("api-token-responses-recall")

      {:ok, _memory} = Memories.create(user, %{"body" => "I use pnpm, not npm."})

      assert conn
             |> post(~p"/api/v1/responses", %{input: "install the deps"})
             |> json_response(200)

      assert_receive {:recorded_request, _id, request}
      assert request.instructions =~ "[From memory: user, "
      assert request.instructions =~ "I use pnpm, not npm."
    end

    test "leaves the caller's own input untouched", %{conn: conn} do
      conn = put_chat_api_token(conn, "responses-recall-input")
      user = github_user("api-token-responses-recall-input")

      {:ok, _memory} = Memories.create(user, %{"body" => "I use pnpm, not npm."})

      assert conn
             |> post(~p"/api/v1/responses", %{
               instructions: "Answer in French.",
               input: "install the deps"
             })
             |> json_response(200)

      assert_receive {:recorded_request, _id, request}
      assert request.input == [%{role: "user", content: "install the deps"}]
      # The note rides below the caller's instructions: material, not an
      # instruction that outranks what the caller asked for.
      assert String.starts_with?(request.instructions, "Answer in French.")
      assert request.instructions =~ "[From memory:"
    end

    test "says what the bounds excluded rather than trailing off", %{conn: conn} do
      previous = Application.get_env(:openagents, :memory_recall) || []

      Application.put_env(
        :openagents,
        :memory_recall,
        Keyword.merge(previous, maximum_attached: 1)
      )

      on_exit(fn -> Application.put_env(:openagents, :memory_recall, previous) end)

      conn = put_chat_api_token(conn, "responses-recall-bounds")
      user = github_user("api-token-responses-recall-bounds")

      for index <- 1..4 do
        {:ok, _memory} = Memories.create(user, %{"body" => "Preference number #{index}."})
      end

      assert conn
             |> post(~p"/api/v1/responses", %{input: "what do you know"})
             |> json_response(200)

      assert_receive {:recorded_request, _id, request}
      assert request.instructions =~ "3 more memories were not attached"
      assert Enum.count(String.split(request.instructions, "[From memory: user,")) == 2
    end

    test "an account with no memories is not told about memory at all", %{conn: conn} do
      conn = put_chat_api_token(conn, "responses-recall-empty")

      assert conn
             |> post(~p"/api/v1/responses", %{input: "install the deps"})
             |> json_response(200)

      assert_receive {:recorded_request, _id, request}
      refute request.instructions =~ "From memory"
    end

    test "never reaches another account's memories", %{conn: conn} do
      other = github_user("responses-recall-other-account")
      {:ok, _theirs} = Memories.create(other, %{"body" => "Deploy with yarn."})

      conn = put_chat_api_token(conn, "responses-recall-mine")

      assert conn
             |> post(~p"/api/v1/responses", %{input: "deploy with yarn"})
             |> json_response(200)

      assert_receive {:recorded_request, _id, request}
      refute request.instructions =~ "From memory"
    end
  end

  # MEMORY-001's amendment, at the surface it changes. Recall is server-side,
  # so the network's memory reaches the model with no tool in the request and
  # no client plumbing at all — and it reaches it only where an operator turned
  # the switch on.
  describe "recall for the system bucket" do
    setup do
      swap_lane(RecordingTestProvider)
      Application.put_env(:openagents, :test_recording_provider_observer, self())
      previous = Application.get_env(:openagents, :memory_recall) || []

      on_exit(fn ->
        Application.delete_env(:openagents, :test_recording_provider_observer)
        Application.put_env(:openagents, :memory_recall, previous)
      end)

      %{settings: previous}
    end

    defp surfacing(settings, enabled?) do
      Application.put_env(
        :openagents,
        :memory_recall,
        Keyword.put(settings, :system_bucket_enabled, enabled?)
      )
    end

    # The steward set is the operator allowlist of GitHub numeric IDs, and the
    # owner's account is in it by definition, so this needs no configuration
    # change.
    defp steward do
      github_id = 14_167_547

      {:ok, user} =
        OpenAgents.Accounts.upsert_github_user(%{
          github_id: github_id,
          github_login: "AtlantisPleb",
          github_avatar_url: "https://avatars.githubusercontent.com/u/#{github_id}?v=4"
        })

      user
    end

    defp system_candidate(author) do
      OpenAgents.Memories.create(author, %{
        "bucket" => "system",
        "slug" => "sys:gateway-402-retired-model",
        "body" => "A 402 from the inference gateway usually means the model was retired.",
        "tier" => "ledger",
        "as_of" => ~D[2026-08-25],
        "admission" => "candidate",
        "evidence_refs" => [
          %{"kind" => "receipt", "ref" => "receipt:4f1c", "digest" => "sha256:9ab3"}
        ]
      })
    end

    test "attaches an admitted memory another account wrote, with no tool in the request",
         %{conn: conn, settings: settings} do
      surfacing(settings, true)

      author = github_user("responses-system-author")
      {:ok, memory} = system_candidate(author)

      {:ok, _record} =
        OpenAgents.Memories.Admissions.record(steward(), memory.id, %{
          "verdict" => "admitted",
          "ground" => "The receipt supports the claim."
        })

      conn = put_chat_api_token(conn, "responses-system-reader")

      assert conn
             |> post(~p"/api/v1/responses", %{input: "the inference gateway returned 402"})
             |> json_response(200)

      assert_receive {:recorded_request, _id, request}
      assert request.instructions =~ "[From memory: (system, as of 2026-08-25, admitted)]"
      assert request.instructions =~ "the model was retired"
      assert request.tool_definitions == []
    end

    test "attaches nothing for a candidate", %{conn: conn, settings: settings} do
      surfacing(settings, true)

      author = github_user("responses-system-candidate-author")
      {:ok, _memory} = system_candidate(author)

      conn = put_chat_api_token(conn, "responses-system-candidate-reader")

      assert conn
             |> post(~p"/api/v1/responses", %{input: "the inference gateway returned 402"})
             |> json_response(200)

      assert_receive {:recorded_request, _id, request}
      refute request.instructions =~ "From memory"
    end

    test "and nothing at all with the switch off", %{conn: conn, settings: settings} do
      surfacing(settings, false)

      author = github_user("responses-system-off-author")
      {:ok, memory} = system_candidate(author)

      {:ok, _record} =
        OpenAgents.Memories.Admissions.record(steward(), memory.id, %{
          "verdict" => "admitted",
          "ground" => "The receipt supports the claim."
        })

      conn = put_chat_api_token(conn, "responses-system-off-reader")

      assert conn
             |> post(~p"/api/v1/responses", %{input: "the inference gateway returned 402"})
             |> json_response(200)

      assert_receive {:recorded_request, _id, request}
      refute request.instructions =~ "From memory"
    end
  end

  describe "an unrecognized caller is unchanged" do
    setup do
      swap_lane(RecordingTestProvider)
      Application.put_env(:openagents, :test_recording_provider_observer, self())
      on_exit(fn -> Application.delete_env(:openagents, :test_recording_provider_observer) end)
      :ok
    end

    test "an anonymous request carries no memory note", %{conn: conn} do
      assert conn
             |> post(~p"/api/v1/responses", %{input: "install the deps"})
             |> json_response(200)

      assert_receive {:recorded_request, _id, request}

      # What this test is about: no memory rides along for a caller we do not
      # recognize. `[From memory: ` is the marker the recognized-caller tests
      # above assert the presence of.
      refute request.instructions =~ "[From memory: "

      # It is still the default prompt, asserted by the properties that prompt
      # exists to hold rather than by its prose. Pinning the whole string made
      # this test fail for a prompt edit that had nothing to do with memory.
      assert request.instructions =~ "You are OpenAgents Coder."
    end

    # The dev lane reaches this route with credentials this endpoint knows
    # nothing about. Recognizing an account must never turn those into a
    # refusal, so an unreadable bearer is answered, not rejected.
    test "an unreadable bearer is answered rather than refused", %{conn: conn} do
      assert conn
             |> put_req_header("authorization", "Bearer not-a-token-this-server-minted")
             |> post(~p"/api/v1/responses", %{input: "hello"})
             |> json_response(200)
    end

    test "a bearer scoped for something else is answered rather than refused", %{conn: conn} do
      assert conn
             |> put_box_api_token("responses-wrong-scope")
             |> post(~p"/api/v1/responses", %{input: "hello"})
             |> json_response(200)

      assert_receive {:recorded_request, _id, request}
      refute request.instructions =~ "From memory"
    end

    test "a malformed authorization header is answered rather than refused", %{conn: conn} do
      assert conn
             |> put_req_header("authorization", "Basic bm90OmJlYXJlcg==")
             |> post(~p"/api/v1/responses", %{input: "hello"})
             |> json_response(200)
    end
  end

  describe "tools through the surface" do
    setup do
      swap_lane(ToolCallingTestProvider)
      :ok
    end

    @tools [
      %{
        type: "function",
        name: "read_conversation",
        description: "Read a conversation back.",
        parameters: %{type: "object", properties: %{}}
      }
    ]

    test "a declared tool comes back as a function_call output item", %{conn: conn} do
      conn = post(conn, ~p"/api/v1/responses", %{input: "read it", tools: @tools})

      assert %{"output" => [_message, call]} = json_response(conn, 200)

      assert %{
               "type" => "function_call",
               "call_id" => "call_1",
               "name" => "read_conversation",
               "arguments" => ~s({"max_turns":4}),
               "status" => "completed"
             } = call
    end

    test "streams the function_call item with its own events", %{conn: conn} do
      conn = post(conn, ~p"/api/v1/responses", %{input: "read it", tools: @tools, stream: true})
      body = response(conn, 200)

      assert body =~ "event: response.function_call_arguments.done"
      assert body =~ ~s("name":"read_conversation")
      assert body =~ ~s("output_index":1)
      assert body =~ "event: response.completed"
    end

    test "replayed calls and outputs reach the provider, and it answers from them", %{conn: conn} do
      conn =
        post(conn, ~p"/api/v1/responses", %{
          input: [
            %{role: "user", content: "read it"},
            %{
              type: "function_call",
              call_id: "call_1",
              name: "read_conversation",
              arguments: ~s({"max_turns":4})
            },
            %{type: "function_call_output", call_id: "call_1", output: "four turns of text"}
          ],
          tools: @tools
        })

      assert %{"output" => [message]} = json_response(conn, 200)
      assert %{"content" => [%{"text" => "The tool said: four turns of text"}]} = message
    end
  end
end