test/openagents/chat/account_turns_test.exs

58e6347eeb72 · 15 KB

defmodule OpenAgents.Chat.AccountTurnsTest do
  use OpenAgents.DataCase

  alias OpenAgents.Chat.AccountTurns
  alias OpenAgents.Chat.OpenRouter.ToolRuntime
  alias OpenAgents.Conversations
  alias OpenAgents.Conversations.Visitor
  alias OpenAgents.Tools.{AdmittedCatalog, OwnerContext, Registry}

  test "an operator can retry the provider failures a retry can fix" do
    for code <- ~w(rate_limited service_unavailable stream_interrupted invalid_response
                   provider_unavailable server_error) do
      assert AccountTurns.retryable_error_code?(code)
    end

    refute AccountTurns.retryable_error_code?("missing_api_key")
    refute AccountTurns.retryable_error_code?(nil)
  end

  test "the API turn names the conversation's visitor, so its tools reach the account" do
    # The regression this guards: the tool context carried `user.id` as
    # `owner_visitor_id`. A User id is not a Visitor id, so `OwnerContext`
    # read back no row and every owner-requiring tool refused a signed-in
    # caller with `owner_not_signed_in`. The value looked plausible and failed
    # somewhere else, which is why it survived. Assert on the resolution, not
    # on the shape of the id.
    user = repository_user_fixture("account-chat-owner-identity")
    test_process = self()

    streamer = fn _request, _callback, options ->
      send(test_process, {:tool_context, Keyword.fetch!(options, :tool_context)})
      {:ok, %{"assistant_content" => "Done.", "assistant_message_id" => "response-1"}}
    end

    assert {:ok, %{"id" => run_id}} =
             AccountTurns.submit(user, "What can you do?",
               subscriber: self(),
               streamer: streamer
             )

    assert_receive {:account_chat_completed, ^run_id, {:ok, _completion}}
    assert_receive {:tool_context, tool_context}

    visitor = Repo.get_by!(Visitor, user_id: user.id)
    assert tool_context.owner_visitor_id == visitor.id
    assert tool_context.owner_user_id == user.id
    refute tool_context.owner_visitor_id == user.id

    assert {:ok, runtime} = ToolRuntime.capture(tool_context: tool_context)
    assert {:ok, resolved} = OwnerContext.resolve(runtime.execution_context)
    assert resolved.id == user.id
  end

  test "a tool the API caller can use now runs for them" do
    # The acceptance criterion, end to end: the same context the turn hands the
    # provider executes an owner-requiring tool and succeeds. Before the fix
    # this returned status "failed" with code "owner_not_signed_in".
    user = repository_user_fixture("account-chat-tool-succeeds")
    test_process = self()

    streamer = fn _request, _callback, options ->
      send(test_process, {:tool_context, Keyword.fetch!(options, :tool_context)})
      {:ok, %{"assistant_content" => "Done.", "assistant_message_id" => "response-1"}}
    end

    assert {:ok, %{"id" => run_id}} =
             AccountTurns.submit(user, "What computers do I have?",
               subscriber: self(),
               streamer: streamer
             )

    assert_receive {:account_chat_completed, ^run_id, {:ok, _completion}}
    assert_receive {:tool_context, tool_context}
    assert {:ok, runtime} = ToolRuntime.capture(tool_context: tool_context)

    assert {:ok, outcome} =
             ToolRuntime.run(runtime, "call-computer-list", "computer_list", "{}")

    assert outcome["status"] == "succeeded"
    assert outcome["error"] == nil
    assert outcome["result"]["schema"] == "sarah.computer_list_result.v1"
  end

  test "the API turn offers the tools the browser offers the same account" do
    user = repository_user_fixture("account-chat-tool-parity")
    {:ok, conversation} = Conversations.ensure_conversation(user)
    owner = Conversations.get_conversation_owner!(conversation)
    intent = "look up why my last delegation failed"
    test_process = self()

    streamer = fn _request, _callback, options ->
      send(test_process, {:tool_context, Keyword.fetch!(options, :tool_context)})
      {:ok, %{"assistant_content" => "Done.", "assistant_message_id" => "response-1"}}
    end

    assert {:ok, %{"id" => run_id}} =
             AccountTurns.submit(user, intent, subscriber: self(), streamer: streamer)

    assert_receive {:account_chat_completed, ^run_id, {:ok, _completion}}
    assert_receive {:tool_context, tool_context}

    assert {:ok, runtime} = ToolRuntime.capture(tool_context: tool_context)
    api_names = runtime |> ToolRuntime.provider_definitions(intent) |> Enum.map(& &1["name"])

    browser_names =
      runtime.snapshot
      |> AdmittedCatalog.provider_definitions(
        OpenAgents.Tools.ConversationExecutionContext.build(%{
          surface: "text",
          conversation_id: conversation.id,
          owner_visitor_id: owner.id,
          owner_user_id: owner.user_id,
          module_registry_snapshot: runtime.snapshot
        }),
        intent
      )
      |> Enum.map(& &1.name)

    assert Enum.sort(api_names) == Enum.sort(browser_names)
    assert "incident_lookup" in api_names
    assert Registry.current!().digest == runtime.snapshot.digest
  end

  test "submit journals the provider lifecycle and projects the same ordered messages" do
    user = repository_user_fixture("account-chat-journal")

    provider_output = [
      %{
        "type" => "reasoning",
        "id" => "reasoning-1",
        "encrypted_content" => "opaque",
        "summary" => [%{"type" => "summary_text", "text" => "Check the repository."}]
      },
      %{
        "type" => "function_call",
        "id" => "function-1",
        "call_id" => "call-1",
        "name" => "read_repository_file",
        "arguments" => ~s({"path":"README.md"}),
        "status" => "completed"
      },
      %{
        "type" => "message",
        "id" => "message-1",
        "role" => "assistant",
        "status" => "completed",
        "content" => [
          %{
            "type" => "output_text",
            "text" => "The repository is available.",
            "annotations" => []
          }
        ]
      }
    ]

    streamer = fn _request, callback, _options ->
      callback.({:reasoning_delta, "Check the repository."})

      callback.(
        {:tool_call_started,
         %{
           "call_id" => "call-1",
           "name" => "read_repository_file",
           "arguments" => ~s({"path":"README.md"})
         }}
      )

      callback.(
        {:tool_call_completed,
         %{
           "call_id" => "call-1",
           "output" =>
             Jason.encode!(%{
               "schema" => "sarah.tool_outcome.v1",
               "status" => "succeeded",
               "result" => %{"content" => "OpenAgents"},
               "error" => nil,
               "workspace" => %{
                 "type" => "forge_worktree",
                 "path" => "/private/var/lib/openagents/workspaces/openagents.com"
               },
               "target_receipt_refs" => ["receipt:forge:abc123"],
               "started_at" => "2026-08-22T19:43:28.000Z",
               "completed_at" => "2026-08-22T19:43:28.125Z"
             })
         }}
      )

      callback.({:text_delta, "The repository is available."})

      {:ok,
       %{
         "assistant_content" => "The repository is available.",
         "assistant_message_id" => "response-1",
         "reasoning_summary" => "Check the repository.",
         "reasoning_items" => [%{"type" => "reasoning", "encrypted_content" => "opaque"}],
         "output" => provider_output
       }}
    end

    assert {:ok, %{"id" => run_id, "status" => "streaming"}} =
             AccountTurns.submit(user, "Read the README.",
               subscriber: self(),
               streamer: streamer
             )

    assert_receive {:account_chat_completed, ^run_id, {:ok, _completion}}

    events = AccountTurns.list_events(user)

    assert Enum.map(events, & &1["type"]) == [
             "user_message",
             "reasoning_delta",
             "tool_call_started",
             "tool_call_completed",
             "text_delta",
             "response_completed"
           ]

    assert Enum.map(events, & &1["sequence"]) == Enum.to_list(1..6)
    assert get_in(List.last(events), ["payload", "reasoning_items"]) != nil

    assert %{
             "status" => "running",
             "state" => "input-available"
           } = Enum.at(events, 2)["tool_call"]

    assert %{
             "status" => "succeeded",
             "state" => "output-available",
             "duration_ms" => 125,
             "workspace" => %{"path" => "openagents.com"},
             "receipt_refs" => ["receipt:forge:abc123"],
             "error" => nil
           } = Enum.at(events, 3)["tool_call"]

    assert [user_message, assistant_message] = AccountTurns.list_messages(user)
    assert user_message.content == "Read the README."
    assert assistant_message.content == "The repository is available."
    assert assistant_message.history?

    assert [
             %{
               name: "read_repository_file",
               state: "output-available",
               status: "succeeded",
               duration_ms: 125,
               workspace_label: "openagents.com",
               receipt_refs: ["receipt:forge:abc123"]
             }
           ] =
             assistant_message.tool_calls

    refute inspect(events) =~ "/private/var/lib/openagents"
    refute inspect(assistant_message) =~ "/private/var/lib/openagents"

    test_process = self()

    follow_up_streamer = fn request, _callback, _options ->
      send(test_process, {:provider_request, request})
      {:ok, %{"assistant_content" => "Continued."}}
    end

    assert {:ok, %{"id" => follow_up_run_id}} =
             AccountTurns.submit(user, "Continue.",
               subscriber: self(),
               streamer: follow_up_streamer
             )

    assert_receive {:provider_request, request}

    assert %{"role" => "assistant", "provider_output" => ^provider_output} =
             Enum.at(request["messages"], 1)

    assert_receive {:account_chat_completed, ^follow_up_run_id, {:ok, _completion}}
  end

  test "projects typed tool errors without losing lifecycle order" do
    user = repository_user_fixture("account-chat-tool-error")

    streamer = fn _request, callback, _options ->
      callback.(
        {:tool_call_started,
         %{
           "call_id" => "call-edit",
           "name" => "edit",
           "arguments" => ~s({"path":"README.md"})
         }}
      )

      callback.(
        {:tool_call_failed,
         %{
           "call_id" => "call-edit",
           "output" => %{
             "schema" => "sarah.tool_outcome.v1",
             "status" => "failed",
             "error" => %{
               "code" => "workspace_read_only",
               "message" => "The workspace is read-only."
             },
             "workspace" => %{
               "type" => "forge_worktree",
               "path" => "/private/var/lib/openagents/workspaces/repo"
             },
             "started_at" => "2026-08-22T19:43:28.000Z",
             "completed_at" => "2026-08-22T19:43:28.004Z"
           }
         }}
      )

      {:ok, %{"assistant_content" => "I could not edit that file."}}
    end

    assert {:ok, %{"id" => run_id}} =
             AccountTurns.submit(user, "Edit the README.", subscriber: self(), streamer: streamer)

    assert_receive {:account_chat_completed, ^run_id, {:ok, _completion}}

    assert Enum.map(AccountTurns.list_events(user), & &1["type"]) == [
             "user_message",
             "tool_call_started",
             "tool_call_failed",
             "response_completed"
           ]

    failed = Enum.at(AccountTurns.list_events(user), 2)["tool_call"]
    assert failed["status"] == "failed"
    assert failed["state"] == "output-error"
    assert failed["duration_ms"] == 4

    assert failed["error"] == %{
             "code" => "workspace_read_only",
             "message" => "The workspace is read-only."
           }
  end

  test "events are isolated by account" do
    user = repository_user_fixture("account-chat-owner")
    other_user = repository_user_fixture("account-chat-other")

    streamer = fn _request, _callback, _options ->
      {:ok, %{"assistant_content" => "Done."}}
    end

    assert {:ok, %{"id" => run_id}} =
             AccountTurns.submit(user, "Private message.",
               subscriber: self(),
               streamer: streamer
             )

    assert_receive {:account_chat_completed, ^run_id, {:ok, _completion}}
    assert AccountTurns.list_events(user) != []
    assert AccountTurns.list_events(other_user) == []
  end

  test "a backend replays only the history it wrote" do
    # An OpenRouter Responses output list is not a Gemini turn. Handing one
    # provider another's transcript shape is a request the provider refuses, so
    # switching backends starts from the turns that backend actually produced.
    user = repository_user_fixture("account-chat-backend-history")
    test_process = self()

    recorder = fn label ->
      fn request, _callback, _options ->
        send(test_process, {:request, label, request})
        {:ok, %{"assistant_content" => "Answered by #{label}."}}
      end
    end

    assert {:ok, %{"id" => first_id, "model" => "glm-5.3-flash"}} =
             AccountTurns.submit(user, "First.", subscriber: self(), streamer: recorder.("ox"))

    assert_receive {:request, "ox", _first_request}
    assert_receive {:account_chat_completed, ^first_id, {:ok, _completion}}

    assert {:ok, %{"id" => second_id, "model" => "gemini-3.7-flash"}} =
             AccountTurns.submit(user, "Second.",
               subscriber: self(),
               backend: "gemini-3.7-flash",
               streamer: recorder.("gemini")
             )

    assert_receive {:request, "gemini", gemini_request}
    assert_receive {:account_chat_completed, ^second_id, {:ok, _completion}}

    # Gemini sees its own first turn and nothing from the GLM 5.3 Flash lane.
    assert gemini_request["messages"] == [%{"role" => "user", "content" => "Second."}]
    assert gemini_request["model"] == "gemini-3.7-flash"

    assert {:ok, %{"id" => third_id}} =
             AccountTurns.submit(user, "Third.", subscriber: self(), streamer: recorder.("ox"))

    assert_receive {:request, "ox", ox_request}
    assert_receive {:account_chat_completed, ^third_id, {:ok, _completion}}

    assert Enum.map(ox_request["messages"], & &1["content"]) == [
             "First.",
             "Answered by ox.",
             "Third."
           ]
  end

  test "a failure names the backend that failed" do
    user = repository_user_fixture("account-chat-backend-error")
    streamer = fn _request, _callback, _options -> {:error, :rate_limited} end

    assert {:ok, %{"id" => run_id}} =
             AccountTurns.submit(user, "Hello.",
               subscriber: self(),
               backend: "gemini-3.7-flash",
               streamer: streamer
             )

    assert_receive {:account_chat_completed, ^run_id, {:error, :rate_limited}}

    assistant = AccountTurns.list_messages(user) |> List.last()
    assert assistant.error =~ "Gemini 3.7 Flash"
    refute assistant.error =~ "OpenRouter"
  end

  test "an unsupported backend never creates a turn" do
    user = repository_user_fixture("account-chat-backend-unknown")

    assert {:error, :unsupported_backend} =
             AccountTurns.submit(user, "Hello.", backend: "gpt-4")

    assert AccountTurns.list_messages(user) == []
  end
end