test/openagents/tools/profile_memory_tools_test.exs

main at 436d37facc96 · 19 KB

defmodule OpenAgents.Tools.ProfileMemoryToolsTest do
  use OpenAgents.DataCase

  alias OpenAgents.Conversations.{Message, Visitor}
  alias OpenAgents.Memory.Consent
  alias OpenAgents.Tools.{ExecutionContext, Registry, Runner}
  alias OpenAgents.{Conversations, ProfileMemory, Repo}

  @tools [
    OpenAgents.Tools.MemoryList,
    OpenAgents.Tools.MemorySearch,
    OpenAgents.Tools.MemoryRemember,
    OpenAgents.Tools.MemoryCorrect,
    OpenAgents.Tools.MemoryForget
  ]

  setup do
    assert {:ok, snapshot} = Registry.build(@tools)
    %{snapshot: snapshot}
  end

  test "first-party UI evidence remains host-only and exact" do
    consent = %{
      "kind" => "first_party_ui",
      "operation" => "remember",
      "claim" => "My project is One"
    }

    assert {:ok, %{kind: "first_party_ui"}} =
             Consent.remember("UI action", "My project is One", consent)

    assert {:error, :memory_consent_mismatch} =
             Consent.remember("UI action", "My project is Two", consent)
  end

  test "natural all-memory forget phrasings are explicit while partial requests stay refused" do
    accepted = [
      "Forget everything you remember about me.",
      "Please forget everything you've stored about me.",
      "Please forget everything you’ve saved about me.",
      "Forget all memories about me",
      "Forget everything in this browser",
      "Delete everything you know about me."
    ]

    for phrase <- accepted do
      assert {:ok, %{kind: "current_message", claim: "all"}} =
               Consent.forget(phrase, "all", "all"),
             "expected acceptance: #{phrase}"
    end

    refused = [
      "Forget everything about my job.",
      "Forget my name.",
      "Maybe you should forget some things."
    ]

    for phrase <- refused do
      assert {:error, :memory_consent_required} = Consent.forget(phrase, "all", "all"),
             "expected refusal: #{phrase}"
    end
  end

  test "record forget accepts an explicit qualified subject without widening authority", %{
    snapshot: snapshot
  } do
    assert {:ok, %{kind: "current_message"}} =
             Consent.forget(
               "Forget my permanent staging qualification shape and remove that lasting profile preference.",
               "record",
               "The user's permanent staging qualification shape is square"
             )

    assert {:error, :memory_consent_mismatch} =
             Consent.forget(
               "Forget my permanent staging qualification shape and remove that lasting profile preference.",
               "record",
               "The user's permanent deployment preference is canary"
             )

    assert {:error, :memory_consent_mismatch} =
             Consent.forget(
               "Forget my name and remove that memory.",
               "record",
               "My name is Chris"
             )

    scope = browser("memory-qualified-forget")

    record =
      remember(
        snapshot,
        scope,
        "preference",
        "The user's permanent staging qualification shape is square"
      )

    message =
      user_message(
        scope.conversation,
        "Forget my permanent staging qualification shape and remove that lasting profile preference."
      )

    assert {:ok, outcome} =
             Runner.run(
               snapshot,
               call("memory_forget", %{
                 "mode" => "record",
                 "record_id" => record.id,
                 "category" => "preference",
                 "claim" => record.claim,
                 "expected_generation" => record.generation
               }),
               context(scope, message)
             )

    assert outcome["result"]["receipt"]["disposition"] == "forgotten"
    assert {:ok, []} = ProfileMemory.list_current(scope.owner)
  end

  test "secret-bearing explicit consent is still refused without echo or storage", %{
    snapshot: snapshot
  } do
    scope = browser("memory-secret-refusal")
    fake_secret = "sk-" <> "proj-" <> String.duplicate("A", 64)
    claim = "my test token is #{fake_secret}"
    message = user_message(scope.conversation, "Remember that #{claim}")

    assert {:ok, refused} =
             Runner.run(
               snapshot,
               call("memory_remember", %{
                 "memories" => [%{"category" => "other", "claim" => claim}]
               }),
               context(scope, message)
             )

    assert refused["status"] == "refused"
    assert refused["error"]["code"] == "memory_policy_refused"
    refute Jason.encode!(refused) =~ fake_secret
    assert {:ok, []} = ProfileMemory.list_current(scope.owner)
  end

  test "remember is durable and idempotent for explicit statements", %{
    snapshot: snapshot
  } do
    scope = browser("memory-explicit")
    message = user_message(scope.conversation, "Remember that I prefer concise answers.")
    context = context(scope, message)

    request =
      call("memory_remember", %{
        "memories" => [
          %{"category" => "preference", "claim" => "I prefer concise answers"}
        ]
      })

    assert {:ok, first} = Runner.run(snapshot, request, context)
    assert first["status"] == "succeeded"
    assert first["result"]["receipt"]["disposition"] == "stored"
    assert [entry] = first["result"]["results"]
    assert entry["memory"]["claim"] == "I prefer concise answers"

    assert {:ok, repeated} = Runner.run(snapshot, %{request | call_id: "call-repeat"}, context)
    assert repeated["result"]["receipt"]["disposition"] == "already_active"

    plain = user_message(scope.conversation, "Remember that I like detailed answers.")

    assert {:ok, automatic} =
             Runner.run(
               snapshot,
               call("memory_remember", %{
                 "memories" => [
                   %{"category" => "preference", "claim" => "I like detailed answers"}
                 ]
               }),
               context(scope, plain)
             )

    assert automatic["status"] == "succeeded"
    assert automatic["result"]["receipt"]["disposition"] == "stored"
    assert {:ok, [_first, _second]} = ProfileMemory.list_current(scope.owner)
  end

  test "a single call with multiple unauthorized claims is refused", %{
    snapshot: snapshot
  } do
    scope = browser("memory-batch")
    message = user_message(scope.conversation, "I'm Chris, I run OpenAgents, and I like Elixir.")

    assert {:ok, refused} =
             Runner.run(
               snapshot,
               call("memory_remember", %{
                 "memories" => [
                   %{"category" => "name", "claim" => "Name is Chris"},
                   %{"category" => "role", "claim" => "Runs OpenAgents"},
                   %{"category" => "preference", "claim" => "Likes Elixir"}
                 ]
               }),
               context(scope, message)
             )

    assert refused["status"] == "refused"
    assert refused["error"]["code"] == "memory_consent_required"
    assert {:ok, []} = ProfileMemory.list_current(scope.owner)
  end

  test "an unrecognized category is coerced to other when the claim is explicitly authorized", %{
    snapshot: snapshot
  } do
    scope = browser("memory-coerced-category")
    message = user_message(scope.conversation, "Remember that I use a split keyboard.")

    assert {:ok, stored} =
             Runner.run(
               snapshot,
               call("memory_remember", %{
                 "memories" => [
                   %{"category" => "Equipment", "claim" => "I use a split keyboard"}
                 ]
               }),
               context(scope, message)
             )

    assert stored["status"] == "succeeded"
    assert [entry] = stored["result"]["results"]
    assert entry["disposition"] == "stored"
    assert entry["category"] == "other"
    assert {:ok, [record]} = ProfileMemory.list_current(scope.owner)
    assert record.category == "other"
  end

  test "a paraphrased claim without exact current or host consent is refused", %{
    snapshot: snapshot
  } do
    scope = browser("memory-automatic")
    statement = user_message(scope.conversation, "I'm mostly working on the One repo lately.")

    assert {:ok, refused} =
             Runner.run(
               snapshot,
               call("memory_remember", %{
                 "memories" => [
                   %{"category" => "project", "claim" => "Mostly working on the One repo"}
                 ]
               }),
               context(scope, statement)
             )

    assert refused["status"] == "refused"
    assert refused["error"]["code"] == "memory_consent_required"
    assert {:ok, []} = ProfileMemory.list_current(scope.owner)
  end

  test "a host-recorded confirmation covers its candidate and refuses a mismatch",
       %{
         snapshot: snapshot
       } do
    scope = browser("memory-confirmation")
    message = user_message(scope.conversation, "Yes, save that.")

    confirmed_context = %{
      context(scope, message)
      | memory_consent: %{
          "kind" => "exact_confirmation",
          "operation" => "remember",
          "claim" => "I prefer concise answers"
        }
    }

    assert_succeeded(
      Runner.run(
        snapshot,
        call("memory_remember", %{
          "memories" => [
            %{"category" => "preference", "claim" => "I prefer concise answers"}
          ]
        }),
        confirmed_context
      )
    )

    assert {:ok, refused} =
             Runner.run(
               snapshot,
               call("memory_remember", %{
                 "memories" => [
                   %{"category" => "preference", "claim" => "I prefer detailed answers"}
                 ]
               }),
               confirmed_context
             )

    assert refused["status"] == "refused"
    assert refused["error"]["code"] == "memory_consent_mismatch"
    assert {:ok, records} = ProfileMemory.list_current(scope.owner)
    refute Enum.any?(records, &(&1.claim == "I prefer detailed answers"))
  end

  test "a fabricated claim with no consent record authorizing it is refused", %{
    snapshot: snapshot
  } do
    scope = browser("memory-fabricated")
    message = user_message(scope.conversation, "Discussing project plans.")

    assert {:ok, refused} =
             Runner.run(
               snapshot,
               call("memory_remember", %{
                 "memories" => [
                   %{"category" => "preference", "claim" => "I prefer detailed answers"}
                 ]
               }),
               context(scope, message)
             )

    assert refused["status"] == "refused"
    assert refused["error"]["code"] == "memory_consent_required"
    assert {:ok, []} = ProfileMemory.list_current(scope.owner)
  end

  test "correct supersedes a conflicting record with the new claim", %{snapshot: snapshot} do
    scope = browser("memory-correct")
    original = remember(snapshot, scope, "name", "Name is Maya")
    message = user_message(scope.conversation, "Actually, I'm Riley now.")

    assert {:ok, corrected} =
             Runner.run(
               snapshot,
               call("memory_correct", %{
                 "record_id" => original.id,
                 "expected_generation" => original.generation,
                 "category" => "name",
                 "claim" => "Name is Riley"
               }),
               context(scope, message)
             )

    assert corrected["status"] == "succeeded"
    assert corrected["result"]["receipt"]["disposition"] == "corrected"

    assert String.starts_with?(
             corrected["result"]["superseded_record_ref"],
             "profile-memory:v1:#{original.id}:"
           )

    assert corrected["result"]["memory"]["claim"] == "Name is Riley"
    assert {:ok, [record]} = ProfileMemory.list_current(scope.owner)
    assert record.claim == "Name is Riley"
    assert record.supersedes_record_id == original.id
  end

  test "correct refuses a stale generation and a foreign record", %{snapshot: snapshot} do
    scope = browser("memory-correct-stale")
    original = remember(snapshot, scope, "role", "Role is data engineer")
    message = user_message(scope.conversation, "I'm a product designer now.")

    assert {:ok, stale} =
             Runner.run(
               snapshot,
               call("memory_correct", %{
                 "record_id" => original.id,
                 "expected_generation" => original.generation + 1,
                 "category" => "role",
                 "claim" => "Role is product designer"
               }),
               context(scope, message)
             )

    refute stale["status"] == "succeeded"

    foreign = browser("memory-correct-foreign")
    foreign_message = user_message(foreign.conversation, "I'm a product designer now.")

    assert {:ok, refused} =
             Runner.run(
               snapshot,
               call("memory_correct", %{
                 "record_id" => original.id,
                 "expected_generation" => original.generation,
                 "category" => "role",
                 "claim" => "Role is product designer"
               }),
               context(foreign, foreign_message)
             )

    refute refused["status"] == "succeeded"
    assert {:ok, [record]} = ProfileMemory.list_current(scope.owner)
    assert record.claim == "Role is data engineer"
    assert {:ok, []} = ProfileMemory.list_current(foreign.owner)
  end

  test "list and search read the frozen turn snapshot", %{snapshot: snapshot} do
    scope = browser("memory-frozen")
    remember_message = user_message(scope.conversation, "Remember that I prefer concise answers.")
    frozen_context = context(scope, remember_message)

    assert_succeeded(
      Runner.run(
        snapshot,
        call("memory_remember", %{
          "memories" => [
            %{"category" => "preference", "claim" => "I prefer concise answers"}
          ]
        }),
        frozen_context
      )
    )

    assert {:ok, frozen_list} =
             Runner.run(
               snapshot,
               call("memory_list", %{"category" => "", "first" => 10}),
               frozen_context
             )

    assert frozen_list["result"]["memories"] == []

    question = user_message(scope.conversation, "What do you remember about concise answers?")
    later_context = context(scope, question)

    assert {:ok, searched} =
             Runner.run(
               snapshot,
               call("memory_search", %{
                 "category" => "",
                 "first" => 10,
                 "query" => "concise"
               }),
               later_context
             )

    assert [memory] = searched["result"]["memories"]
    assert memory["claim"] == "I prefer concise answers"
    assert searched["result"]["snapshot_ref"] == later_context.profile_memory_snapshot_ref
  end

  test "record, category, and all-browser forget are explicit and idempotent", %{
    snapshot: snapshot
  } do
    scope = browser("memory-forget")
    preference = remember(snapshot, scope, "preference", "I prefer concise answers")
    _project = remember(snapshot, scope, "project", "My project is One")
    message = user_message(scope.conversation, "Forget that I prefer concise answers.")

    arguments = %{
      "mode" => "record",
      "record_id" => preference.id,
      "category" => "",
      "claim" => preference.claim,
      "expected_generation" => preference.generation
    }

    assert {:ok, forgotten} =
             Runner.run(snapshot, call("memory_forget", arguments), context(scope, message))

    assert forgotten["result"]["receipt"]["disposition"] == "forgotten"

    assert {:ok, repeated} =
             Runner.run(snapshot, call("memory_forget", arguments), context(scope, message))

    assert repeated["result"]["receipt"]["disposition"] == "already_absent"

    category_message = user_message(scope.conversation, "Forget all project memories.")

    assert_succeeded(
      Runner.run(
        snapshot,
        call("memory_forget", %{
          "mode" => "category",
          "record_id" => "",
          "category" => "project",
          "claim" => "",
          "expected_generation" => 0
        }),
        context(scope, category_message)
      )
    )

    all_message = user_message(scope.conversation, "Forget everything you remember about me.")

    assert_succeeded(
      Runner.run(
        snapshot,
        call("memory_forget", %{
          "mode" => "all",
          "record_id" => "",
          "category" => "",
          "claim" => "",
          "expected_generation" => 0
        }),
        context(scope, all_message)
      )
    )

    assert {:ok, []} = ProfileMemory.list_current(scope.owner)
  end

  test "a foreign record is indistinguishable from an absent record", %{snapshot: snapshot} do
    first = browser("memory-first-browser")
    second = browser("memory-second-browser")
    record = remember(snapshot, first, "other", "My marker is cedar")
    message = user_message(second.conversation, "Forget that My marker is cedar.")

    assert {:ok, outcome} =
             Runner.run(
               snapshot,
               call("memory_forget", %{
                 "mode" => "record",
                 "record_id" => record.id,
                 "category" => "",
                 "claim" => record.claim,
                 "expected_generation" => record.generation
               }),
               context(second, message)
             )

    assert outcome["status"] == "succeeded"
    assert outcome["result"]["receipt"]["disposition"] == "already_absent"
    assert {:ok, [_active]} = ProfileMemory.list_current(first.owner)
    assert {:ok, []} = ProfileMemory.list_current(second.owner)
  end

  defp browser(key) do
    assert {:ok, conversation} = Conversations.ensure_conversation(key)
    %{owner: Repo.get!(Visitor, conversation.visitor_id), conversation: conversation}
  end

  defp user_message(conversation, content) do
    Repo.insert!(%Message{
      conversation_id: conversation.id,
      role: "user",
      status: "complete",
      content: content
    })
  end

  defp context(scope, message) do
    assert {:ok, profile_snapshot} = ProfileMemory.capture_snapshot(scope.owner)

    %ExecutionContext{
      scope: "browser_conversation",
      scope_ref: "conversation:#{scope.conversation.id}",
      authorities: MapSet.new(["memory.read", "memory.write"]),
      conversation_id: scope.conversation.id,
      current_user_message_id: message.id,
      owner_visitor_id: scope.owner.id,
      profile_memory_snapshot_ref: profile_snapshot.ref
    }
  end

  defp remember(snapshot, scope, category, claim) do
    message = user_message(scope.conversation, "Remember that #{claim}.")

    assert {:ok, outcome} =
             Runner.run(
               snapshot,
               call("memory_remember", %{
                 "memories" => [%{"category" => category, "claim" => claim}]
               }),
               context(scope, message)
             )

    assert outcome["status"] == "succeeded"
    assert {:ok, records} = ProfileMemory.list_current(scope.owner)
    Enum.find(records, &(&1.category == category and &1.claim == claim))
  end

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

  defp assert_succeeded({:ok, outcome}), do: assert(outcome["status"] == "succeeded")
end