defmodule OpenAgents.ConversationsTest do
use OpenAgents.DataCase, async: true
alias OpenAgents.Conversations
alias OpenAgents.Conversations.{Message, Visitor}
test "one opaque browser key always resolves to one durable conversation" do
token = "browser-key-one"
assert {:ok, first} = Conversations.ensure_conversation(token)
assert {:ok, second} = Conversations.ensure_conversation(token)
assert first.id == second.id
visitor = Repo.get_by!(Visitor, id: first.visitor_id)
refute visitor.browser_key_hash == token
assert visitor.browser_key_hash == :crypto.hash(:sha256, token)
{messages, false} = Conversations.list_messages(first)
assert [%Message{role: "assistant", status: "complete", content: greeting}] = messages
assert greeting == "Hello. I'm Sarah—an OpenAgent. What are we working on?"
end
test "different browser keys receive different canonical conversations" do
assert {:ok, first} = Conversations.ensure_conversation("browser-key-one")
assert {:ok, second} = Conversations.ensure_conversation("browser-key-two")
refute first.id == second.id
end
test "turn creation persists both messages and enforces one active turn" do
assert {:ok, conversation} = Conversations.ensure_conversation("turn-browser")
assert {:ok, records} = Conversations.create_turn(conversation, " Think with me. ")
assert records.user_message.content == "Think with me."
assert records.user_message.status == "complete"
assert records.assistant_message.status == "streaming"
assert records.turn.status == "queued"
assert {:error, :turn_in_progress} = Conversations.create_turn(conversation, "Another turn")
assert {:ok, completed} = Conversations.complete_turn(records.turn, "response-1")
assert completed.status == "completed"
assert Conversations.active_turn(conversation) == nil
end
test "a failed turn persists the real error code, not just the public message" do
assert {:ok, conversation} = Conversations.ensure_conversation("failcode-browser")
assert {:ok, records} = Conversations.create_turn(conversation, "Delegate something big")
assert {:ok, failed} =
Conversations.fail_turn(records.turn, {:transport, :provider_task_exited})
# The user-facing message stays generic; the machine-readable reason is now
# durable so Sarah and the owner can diagnose it (previously it was dropped).
assert failed.status == "failed"
assert failed.error_message =~ "could not finish"
assert failed.error_code == "transport"
end
test "fail_turn types string, exception, and 3-tuple reasons instead of unknown" do
assert {:ok, conversation} = Conversations.ensure_conversation("failcode-shapes-browser")
assert {:ok, string_records} = Conversations.create_turn(conversation, "string reason")
assert {:ok, string_failed} =
Conversations.fail_turn(string_records.turn, "econnreset from the socket")
assert string_failed.error_code == "task_exit"
refute string_failed.error_code == "unknown"
assert {:ok, conversation} = Conversations.ensure_conversation("failcode-shapes-browser-2")
assert {:ok, exception_records} = Conversations.create_turn(conversation, "exception reason")
assert {:ok, exception_failed} =
Conversations.fail_turn(exception_records.turn, %RuntimeError{message: "boom"})
assert exception_failed.error_code == "task_exit:RuntimeError"
refute exception_failed.error_code == "unknown"
assert {:ok, conversation} = Conversations.ensure_conversation("failcode-shapes-browser-3")
assert {:ok, triple_records} = Conversations.create_turn(conversation, "triple reason")
assert {:ok, triple_failed} =
Conversations.fail_turn(
triple_records.turn,
{:transport, :provider_task_exited, :monitor}
)
assert triple_failed.error_code == "transport"
refute triple_failed.error_code == "unknown"
end
test "message validation rejects empty and oversized input before persistence" do
assert {:ok, conversation} = Conversations.ensure_conversation("validation-browser")
assert {:error, :empty_message} = Conversations.create_turn(conversation, " ")
assert {:error, :message_too_long} =
Conversations.create_turn(conversation, String.duplicate("x", 8_001))
end
test "assistant streaming stops before the accumulated message exceeds its byte budget" do
assert {:ok, conversation} = Conversations.ensure_conversation("assistant-bound-browser")
assert {:ok, records} = Conversations.create_turn(conversation, "Keep the reply bounded")
maximum = Application.fetch_env!(:openagents, :maximum_message_bytes)
assert {:ok, message} =
Conversations.append_assistant_delta(records.turn, String.duplicate("a", maximum))
assert byte_size(message.content) == maximum
assert {:error, :assistant_message_limit_reached} =
Conversations.append_assistant_delta(records.turn, "b")
assert byte_size(Repo.get!(Message, records.assistant_message.id).content) == maximum
end
test "startup recovery makes interrupted turns explicitly failed" do
assert {:ok, conversation} = Conversations.ensure_conversation("recovery-browser")
assert {:ok, records} = Conversations.create_turn(conversation, "Do not leave this pending")
assert :ok = Conversations.recover_interrupted_turns()
recovered_turn = Repo.get!(OpenAgents.Conversations.Turn, records.turn.id)
recovered_message = Repo.get!(Message, records.assistant_message.id)
assert recovered_turn.status == "failed"
assert recovered_turn.error_message =~ "restarted"
assert recovered_message.status == "failed"
end
test "history is bounded and can be paged backward without duplication" do
assert {:ok, conversation} = Conversations.ensure_conversation("history-browser")
for number <- 1..45 do
%Message{}
|> Message.changeset(%{
conversation_id: conversation.id,
role: "user",
content: "message #{number}",
status: "complete"
})
|> Repo.insert!()
end
{recent, true} = Conversations.list_messages(conversation)
assert length(recent) == 40
{older, false} = Conversations.list_messages(conversation, hd(recent).id)
assert length(older) == 6
assert MapSet.disjoint?(MapSet.new(recent, & &1.id), MapSet.new(older, & &1.id))
end
describe "user_has_messages?/1" do
# This decides whether the agent's surfaces appear in the sidebar at all:
# an account that has already talked to her keeps them, a new one never
# sees them. Getting it wrong in the permissive direction is invisible --
# the section simply shows for everybody -- so the greeting case below is
# the one that matters.
test "an account that has never opened chat has not sent a message" do
user = OpenAgents.AccountsFixtures.repository_user_fixture("never-chatted")
refute Conversations.user_has_messages?(user)
end
test "the seeded greeting does not count as the person having written" do
# Every conversation is created with an assistant greeting already in it,
# so "does this account have any message" is true the instant chat is
# opened -- which would grandfather every account that ever loaded the
# page. The question has to be about a message the person wrote.
user = OpenAgents.AccountsFixtures.repository_user_fixture("only-greeted")
assert {:ok, conversation} = Conversations.ensure_conversation(user)
{[%Message{role: "assistant"}], false} = Conversations.list_messages(conversation)
refute Conversations.user_has_messages?(user)
end
test "an account that has written is grandfathered" do
user = OpenAgents.AccountsFixtures.repository_user_fixture("has-chatted")
assert {:ok, conversation} = Conversations.ensure_conversation(user)
assert {:ok, _records} = Conversations.create_turn(conversation, "Hello.")
assert Conversations.user_has_messages?(user)
end
test "one account's messages do not grandfather another" do
talker = OpenAgents.AccountsFixtures.repository_user_fixture("talker")
bystander = OpenAgents.AccountsFixtures.repository_user_fixture("bystander")
assert {:ok, conversation} = Conversations.ensure_conversation(talker)
assert {:ok, _records} = Conversations.create_turn(conversation, "Hello.")
refute Conversations.user_has_messages?(bystander)
end
test "no account at all has no messages" do
refute Conversations.user_has_messages?(nil)
end
end
end