test/openagents/experience_memory_test.exs

main at 58e6347eeb72 · 15 KB

defmodule OpenAgents.ExperienceMemoryTest do
  use OpenAgents.DataCase, async: false
  alias OpenAgents.{Context.Composer, Conversations, ExperienceMemory}
  alias OpenAgents.ExperienceMemory.{Bank, BankItem, DeletionReceipt, EvidenceRef, Pattern}
  alias OpenAgents.Providers.Request

  setup do
    original = Application.fetch_env!(:openagents, :experience_memory)
    Application.put_env(:openagents, :experience_memory, Keyword.put(original, :enabled, true))
    on_exit(fn -> Application.put_env(:openagents, :experience_memory, original) end)
    :ok
  end

  test "requested and running cases are never recalled, and success requires a real target receipt" do
    fixture = conversation_fixture("experience-lifecycle")
    record = create_case!(fixture, "Deploy the service", "Run the bounded deployment module")

    assert {:ok, requested_bank} = capture(fixture, "deploy service")

    assert requested_bank.projections == []
    assert {:ok, running} = ExperienceMemory.start_case(fixture.owner, record.id, 1)

    assert {:ok, running_bank} = capture(fixture, "deploy service")

    assert running_bank.projections == []

    assert {:error, :target_receipt_required} =
             ExperienceMemory.complete_case(fixture.owner, running.id, 2, %{
               "outcome_state" => "succeeded",
               "outcome" => "The service answered its health check.",
               "target_receipt_refs" => []
             })

    target_ref = successful_target!(fixture, "experience-lifecycle-target")

    assert {:ok, succeeded} =
             ExperienceMemory.complete_case(fixture.owner, running.id, 2, %{
               "outcome_state" => "succeeded",
               "outcome" => "The service answered its health check.",
               "target_receipt_refs" => [target_ref]
             })

    assert succeeded.outcome_state == "succeeded"
    assert {:ok, bank} = capture(fixture, "deploy")
    assert [projection] = bank.projections
    assert projection["state"] == "succeeded"
    assert projection["target_receipt_refs"] == [target_ref]
    assert projection["applicability"] == "Only similar work in this conversation"
    assert projection["interpretation"] =~ "not universal"
  end

  test "failed cases remain explicit, private, and scope isolated" do
    fixture = conversation_fixture("experience-private")
    failed = failed_case!(fixture, "Rotate a key", "Attempt provider rotation")
    other = conversation_fixture("experience-other-owner")

    assert failed.outcome_state == "failed"
    assert {:error, :not_found} = ExperienceMemory.get(other.owner, failed.id)
    assert {:error, :scope_refused} = ExperienceMemory.export_scope(other.owner, fixture.scope)

    assert {:ok, bank} = capture(fixture, "rotate key")

    assert [projection] = bank.projections
    assert projection["state"] == "failed"
    assert projection["interpretation"] == "one scoped failure; advisory caution"
  end

  test "pattern evidence requires multiple terminal cases and selection is deterministic and bounded" do
    fixture = conversation_fixture("experience-pattern")
    first = failed_case!(fixture, "Check a release", "Inspect the release receipt")

    assert {:error, :insufficient_pattern_support} =
             ExperienceMemory.create_pattern(fixture.owner, fixture.scope, %{
               "phenomenon" => "Release receipts expose incomplete promotions",
               "applicability" => "Digest-addressed releases",
               "expected_effect" => "Inspecting receipts finds incomplete promotion",
               "confidence_millis" => 700,
               "support_record_ids" => [first.id]
             })

    second = failed_case!(fixture, "Audit another release", "Inspect its release receipt")

    assert {:ok, pattern} =
             ExperienceMemory.create_pattern(fixture.owner, fixture.scope, %{
               "phenomenon" => "Release receipts expose incomplete promotions",
               "applicability" => "Digest-addressed releases",
               "expected_effect" => "Inspecting receipts finds incomplete promotion",
               "confidence_millis" => 700,
               "support_record_ids" => [first.id, second.id]
             })

    assert pattern.status == "active"
    turn = fresh_turn!(fixture, "release receipt")

    assert {:ok, first_bank} =
             ExperienceMemory.capture_for_turn(fixture.owner, turn, "release receipt")

    assert {:ok, second_bank} =
             ExperienceMemory.capture_for_turn(fixture.owner, turn, "changed retry query")

    assert first_bank.ref == second_bank.ref
    assert first_bank.projections == second_bank.projections
    assert first_bank.usage == second_bank.usage
    assert first_bank.usage["pattern_refs"] == ["experience-pattern:#{pattern.id}"]

    bank_id = bank_id!(first_bank.ref)
    persisted = Repo.get!(Bank, bank_id)
    assert persisted.used_bytes <= 4_000
    assert Repo.aggregate(from(i in BankItem, where: i.bank_id == ^bank_id), :count) <= 9
  end

  test "delete removes source-linked derivatives and leaves an immutable bounded receipt" do
    fixture = conversation_fixture("experience-delete")
    first = failed_case!(fixture, "Investigate latency", "Inspect the trace")
    second = failed_case!(fixture, "Investigate another latency event", "Inspect the trace")

    assert {:ok, _pattern} =
             ExperienceMemory.create_pattern(fixture.owner, fixture.scope, %{
               "phenomenon" => "Trace inspection identifies latency failures",
               "applicability" => "Latency investigations",
               "expected_effect" => "Relevant failure evidence is recovered",
               "confidence_millis" => 650,
               "support_record_ids" => [first.id, second.id]
             })

    assert {:ok, captured} = capture(fixture, "latency trace")
    bank_id = bank_id!(captured.ref)

    assert {:ok, receipt} = ExperienceMemory.delete(fixture.owner, first.id, "owner_requested")
    assert receipt.source_ref_count > 0
    assert receipt.pattern_count == 1
    assert receipt.bank_item_count > 0
    assert Repo.get!(Bank, bank_id).status == "invalidated"
    refute Repo.exists?(from(r in EvidenceRef, where: r.record_id == ^first.id))
    refute Repo.exists?(from(p in Pattern, where: p.owner_visitor_id == ^fixture.owner.id))

    assert {:ok, exported} = ExperienceMemory.export_scope(fixture.owner, fixture.scope)
    refute Enum.any?(exported["records"], &(&1["record_ref"] == "experience:#{first.id}"))

    assert_raise Postgrex.Error, fn ->
      receipt |> Ecto.Changeset.change(reason_code: "rewritten") |> Repo.update!()
    end

    assert Repo.aggregate(DeletionReceipt, :count) == 1
  end

  test "correction is atomic and collective candidacy remains a separate explicit-consent path" do
    fixture = conversation_fixture("experience-correction")
    failed = failed_case!(fixture, "Prepare a rollout", "Use the incomplete rollout plan")

    replacement = %{
      "objective" => "Prepare a corrected rollout",
      "approach" => "Use a staged rollout with a health gate",
      "applicability" => "Only staged rollouts in this conversation",
      "confidence_millis" => 700,
      "source_refs" => ["message:#{fixture.source_message.id}"],
      "trace_refs" => []
    }

    assert {:error, :explicit_contribution_consent_required} =
             ExperienceMemory.propose_collective_candidate(fixture.owner, failed.id, %{
               "actor_type" => "person",
               "explicit" => false
             })

    assert {:ok, %{candidate: candidate}} =
             ExperienceMemory.propose_collective_candidate(fixture.owner, failed.id, %{
               "actor_type" => "person",
               "explicit" => true,
               "confirmation_kind" => "collective_contribution",
               "confirmation_nonce" => "experience-correction-consent",
               "category" => "module_pattern",
               "intended_use" => "Evaluate a possible collective rollout pattern.",
               "attribution_disclosure" => "Attribution remains attached to the contribution.",
               "compensation_disclosure" => "Consent does not promise compensation."
             })

    assert candidate.status == "consented"
    assert candidate.publication_refs == []
    assert candidate.generalized_payload == nil

    assert {:ok, result} =
             ExperienceMemory.correct_case(
               fixture.owner,
               failed.id,
               failed.generation,
               "The original approach omitted the health gate.",
               replacement
             )

    assert result.corrected.outcome_state == "corrected"
    assert result.replacement.supersedes_record_id == failed.id
    assert result.replacement.outcome_state == "requested"

    assert Repo.exists?(
             from(r in EvidenceRef,
               where:
                 r.record_id == ^failed.id and r.kind == "correction" and
                   r.reference == ^"experience:#{result.replacement.id}"
             )
           )
  end

  test "experience memory is a measured feature flag and defaults to no capture" do
    fixture = conversation_fixture("experience-disabled")
    original = Application.fetch_env!(:openagents, :experience_memory)
    Application.put_env(:openagents, :experience_memory, Keyword.put(original, :enabled, false))

    assert {:ok, disabled} = capture(fixture, "anything")

    assert disabled == %{
             ref: nil,
             projections: [],
             usage: %{
               "schema" => "sarah.experience_usage.v1",
               "record_refs" => [],
               "pattern_refs" => [],
               "bank_digest" => nil
             }
           }

    fixture_path =
      Path.join(:code.priv_dir(:openagents), "sarah/evals/experiences/benefit-comparison.v1.json")

    evaluation = fixture_path |> File.read!() |> Jason.decode!()
    assert evaluation["activation_gate"]["default_enabled"] == false
    assert evaluation["activation_gate"]["requires_measured_benefit"] == true
  end

  defp conversation_fixture(browser_key) do
    assert {:ok, conversation} = Conversations.ensure_conversation(browser_key)
    owner = Repo.get!(OpenAgents.Conversations.Visitor, conversation.visitor_id)

    assert {:ok, records} =
             Conversations.create_turn(conversation, "Source evidence for this work.")

    %{
      owner: owner,
      conversation: conversation,
      source_message: records.user_message,
      scope: "conversation:#{conversation.id}"
    }
  end

  defp create_case!(fixture, objective, approach) do
    assert {:ok, record} =
             ExperienceMemory.create_case(fixture.owner, fixture.scope, %{
               "objective" => objective,
               "approach" => approach,
               "applicability" => "Only similar work in this conversation",
               "confidence_millis" => 600,
               "source_refs" => ["message:#{fixture.source_message.id}"],
               "trace_refs" => []
             })

    record
  end

  defp failed_case!(fixture, objective, approach) do
    record = create_case!(fixture, objective, approach)

    assert {:ok, running} =
             ExperienceMemory.start_case(fixture.owner, record.id, record.generation)

    assert {:ok, failed} =
             ExperienceMemory.complete_case(fixture.owner, running.id, running.generation, %{
               "outcome_state" => "failed",
               "outcome" => "The scoped objective was not achieved.",
               "target_receipt_refs" => []
             })

    failed
  end

  defp capture(fixture, query) do
    turn = fresh_turn!(fixture, query)
    ExperienceMemory.capture_for_turn(fixture.owner, turn, query)
  end

  defp successful_target!(fixture, suffix) do
    turn = fresh_turn!(fixture, "Produce a target receipt.")
    context = Composer.compose!()

    request = %Request{
      model_id: "experience-test-model",
      instructions: context.instructions,
      input: Conversations.provider_messages(fixture.conversation.id)
    }

    assert {:ok, inference} =
             Conversations.begin_inference(
               turn,
               context,
               request,
               "test.provider",
               tool_catalog_digest: OpenAgents.Tools.Registry.current!().digest
             )

    artifact =
      Map.fetch!(OpenAgents.Tools.Registry.current!().modules, {"sarah.tool.recall_messages", 1})

    call_id = "call-#{suffix}"
    route = route!(inference.receipt, call_id, artifact)
    policy = artifact.attribution_policy

    assert {:ok, step, :created} =
             Conversations.request_tool_step(inference.turn, inference.receipt, %{
               provider_call_id: call_id,
               provider_item_id: "item-#{suffix}",
               provider_response_id: "response-#{suffix}",
               tool_name: "recall_messages",
               tool_version: artifact.version,
               module_id: artifact.module_id,
               module_artifact_digest: artifact.artifact_digest,
               executor_implementation_digest: artifact.implementation_digest,
               routing_receipt_id: route.id,
               side_effect_class: artifact.side_effect_class,
               attribution_policy_id: policy["id"],
               attribution_policy_version: policy["version"],
               attribution_policy_digest: policy["digest"],
               cost_units: artifact.facets["cost_units"],
               raw_arguments: "{}"
             })

    target_ref = "target:verified:#{suffix}"

    outcome = %{
      "schema" => "sarah.tool_outcome.v1",
      "call_id" => step.provider_call_id,
      "module_ref" => %{
        "module_id" => step.module_id,
        "tool_name" => step.tool_name,
        "version" => step.tool_version,
        "artifact_digest" => step.module_artifact_digest
      },
      "executor_ref" => %{
        "id" => "sarah.local",
        "disclosure" => "Sarah local recall",
        "implementation_digest" => step.executor_implementation_digest
      },
      "status" => "succeeded",
      "result" => %{"verified" => true},
      "error" => nil,
      "target_receipt_refs" => [target_ref],
      "attribution_refs" => ["OpenAgentsInc/openagents.com"],
      "started_at" => "2026-08-16T20:00:00Z",
      "completed_at" => "2026-08-16T20:00:01Z"
    }

    assert {:ok, _completed} = Conversations.complete_tool_step(step, outcome)
    target_ref
  end

  defp route!(receipt, call_id, artifact) do
    snapshot = OpenAgents.Tools.Registry.current!()

    proposal = %{
      "module_id" => artifact.module_id,
      "version" => artifact.version,
      "artifact_digest" => artifact.artifact_digest,
      "registry_digest" => snapshot.digest
    }

    assert {:ok, decision} =
             OpenAgents.Modules.Router.route(
               snapshot,
               OpenAgents.Modules.RoutingPolicy.default(),
               %{
                 intent_digest: receipt.input_digest,
                 required_capability: "conversation.read",
                 required_side_effect: "read_only",
                 surface: "text",
                 data_scope: "browser_conversation",
                 authorities: MapSet.new(["conversation.read"]),
                 proposal: proposal,
                 exact_proposal: true
               }
             )

    assert {:ok, route} =
             OpenAgents.Modules.RoutingReceipts.persist(receipt.id, call_id, decision)

    route
  end

  defp fresh_turn!(fixture, content) do
    active =
      Repo.one(
        from(t in OpenAgents.Conversations.Turn,
          where:
            t.conversation_id == ^fixture.conversation.id and
              t.status in ["queued", "streaming", "cancelling"]
        )
      )

    if active do
      assert {:ok, _cancelled} = Conversations.cancel_turn(active)
    end

    assert {:ok, records} = Conversations.create_turn(fixture.conversation, content)
    records.turn
  end

  defp bank_id!("experience-bank:v1:" <> id), do: id
end