test/openagents_web/live/chat_live_test.exs

58e6347eeb72 · 49 KB

defmodule OpenAgentsWeb.ChatLiveTest do
  use OpenAgentsWeb.ConnCase
  import Phoenix.LiveViewTest
  import Ecto.Query

  alias OpenAgents.{Context.Composer, Conversations, ProfileMemory, Voice}
  alias OpenAgents.Conversations.{Message, Visitor}
  alias OpenAgents.Providers.Request

  test "the composer takes focus when the conversation opens", %{conn: conn} do
    conn = log_in_github_user(conn, "composer-focus-browser")
    {:ok, view, _html} = live(conn, ~p"/sarah")

    # Declarative rather than a hook call, so it also fires when the composer
    # remounts - returning from the memory surface, for instance.
    assert has_element?(view, "#chat_message[phx-mounted]")
  end

  test "the composer error renders as the eyebrow inside the card", %{conn: conn} do
    conn = log_in_github_user(conn, "composer-eyebrow-browser")
    {:ok, view, _html} = live(conn, ~p"/sarah")

    view
    |> form("#message-form", chat: %{message: "   "})
    |> render_submit()

    # The error is part of the card rather than a full-width band above the
    # form: same id, same aria-describedby wiring, now inside the form.
    assert has_element?(view, "#message-form #composer-error.composer-eyebrow")
    assert has_element?(view, ~s(#chat_message[aria-describedby="composer-error"]))

    # The send action stays an icon-only control carrying its accessible name.
    assert has_element?(view, ~s(#message-form #send-message[aria-label="Send"]))
  end

  test "sending a message resets the composer so the draft cannot stick", %{conn: conn} do
    conn = log_in_github_user(conn, "composer-clear-browser")
    {:ok, view, _html} = live(conn, ~p"/sarah")

    view
    |> form("#message-form", chat: %{message: "The draft must not remain after send."})
    |> render_submit()

    assert view |> element("#chat_message") |> render() =~
             ~r/<textarea[^>]*id="chat_message"[^>]*>\s*<\/textarea>/

    assert eventually(fn ->
             html = render(view)

             html =~ "You said: The draft must not remain after send." and
               not (html =~ ~s(id="cancel-turn"))
           end)
  end

  test "the sidebar carries the shared destinations for an account that has written",
       %{conn: conn} do
    conn = log_in_chatting_user(conn, "computers-nav-browser")
    {:ok, view, _html} = live(conn, ~p"/sarah")

    # Computers, Memory and Leaderboard are destinations for everyone, so they
    # sit in the application sidebar rather than appearing only on chat.
    assert has_element?(view, ~s(#sidebar a.sidebar-row__hit[href="/computers"]))
    assert has_element?(view, ~s(#sidebar a.sidebar-row__hit[href="/memory"]))
    # Leaderboard is a secondary destination, so it sits in the footer with the
    # docs and the component library rather than in the working nav.
    assert has_element?(view, ~s(#sidebar .sidebar-footer a[href="/leaderboard"]))
  end

  test "chat contributes its rows to the one application sidebar", %{conn: conn} do
    conn = log_in_github_user(conn, "sidebar-shell-browser")
    {:ok, view, _html} = live(conn, ~p"/sarah")

    # Chat used to render a second, complete application shell inside the
    # first: its own brand, rail and account footer, nested in the padded main
    # of a layout that already had all three. It now contributes rows to the
    # sidebar the layout owns, so there is exactly one of each.
    assert has_element?(view, "#sidebar")
    assert view |> render() |> String.split("<aside") |> length() == 2
    assert has_element?(view, "#app-shell")
    assert has_element?(view, ~s(#sidebar[aria-hidden="true"]))
    assert has_element?(view, ~s(#sidebar-scrim[aria-hidden="true"]))
    refute has_element?(view, "#mobile-menu")

    assert has_element?(
             view,
             ~s(#sidebar-expand-toggle[aria-controls="sidebar"][aria-expanded="false"])
           )

    assert has_element?(
             view,
             ~s(#sidebar-collapse-toggle[aria-controls="sidebar"][aria-expanded="false"])
           )

    # Every destination chat used to carry in its own rail is still reachable,
    # with an accessible name on each stretched hit target.
    # Export is the conversation's action, so it is in the conversation's
    # header menu rather than a permanent sidebar row.
    refute has_element?(view, "#sidebar #export-atif")

    assert has_element?(
             view,
             "#chat-actions-menu a#export-atif[href='/data/export/atif'][download]"
           )

    # Chat's own sections region held one thing, the work projection, and went
    # with it when `/chat` was zero-based. What chat still contributes to the
    # sidebar is the conversation's data action, covered by the reset test
    # below.
    refute has_element?(view, "#sidebar #sidebar-sections")

    # Identity is the command bar's, once, rather than a second account
    # control in a second footer.
    assert has_element?(view, "#account-bar-trigger")
    refute has_element?(view, "#sidebar #account-menu-trigger")
  end

  test "the admin row renders only for an operator", %{conn: conn} do
    conn = log_in_github_user(conn, "admin-chip-hidden-browser")
    {:ok, _view, html} = live(conn, ~p"/sarah")
    refute html =~ ~s(id="open-admin")

    conn = log_in_admin_user(recycle(conn), "admin-chip-visible-browser")
    {:ok, view, _html} = live(conn, ~p"/sarah")
    # Admin is one row in the sidebar footer for an operator on every page,
    # rather than a row that exists only on chat.
    assert has_element?(view, ~s(#sidebar .sidebar-footer #open-admin[href="/admin"]))
  end

  test "the reset control renders only where it is enabled", %{conn: conn} do
    original = Application.get_env(:openagents, :conversation_reset_enabled, false)
    on_exit(fn -> Application.put_env(:openagents, :conversation_reset_enabled, original) end)

    Application.put_env(:openagents, :conversation_reset_enabled, true)
    conn = log_in_github_user(conn, "reset-visible-browser")
    {:ok, _view, html} = live(conn, ~p"/sarah")
    assert html =~ ~s(id="reset-conversation")

    Application.put_env(:openagents, :conversation_reset_enabled, false)
    conn = log_in_github_user(recycle(conn), "reset-hidden-browser")
    {:ok, _view, html} = live(conn, ~p"/sarah")
    refute html =~ ~s(id="reset-conversation")
  end

  test "plain message text is flush with its tags so pre-wrap renders nothing extra" do
    # Ported from Sarah: the file this reads was `lib/sarah_web/live/chat_live.ex`
    # there. Only the path changed with the re-namespacing — the assertion below
    # is unchanged and still guards the same markup.
    template = File.read!("lib/openagents_web/live/chat_live.ex")

    # `.message-content` is `white-space: pre-wrap`, which is what preserves the
    # line breaks a person actually typed. Any newline or indentation the
    # formatter puts around the interpolation becomes content and renders as a
    # blank line, so the interpolation stays flush and `phx-no-format` keeps it
    # that way. Assistant prose goes through Markdown and is not affected.
    assert template =~ ~r/phx-no-format\s*\n\s*>\{@message\.content\}<\/p>/
  end

  test "presents one continuing conversation after authentication", %{conn: conn} do
    user = github_user("live-browser")
    conn = log_in_github_user(conn, "live-browser")
    assert {:ok, view, html} = live(conn, ~p"/sarah")

    assert html =~ "OpenAgents"
    assert html =~ "Message Sarah"
    assert html =~ "Hello. I&#39;m Sarah—an OpenAgent. What are we working on?"
    assert html =~ ~s(href="/favicon.ico")
    assert html =~ ~s(href="/favicon-32x32.png")
    assert html =~ ~s(href="/favicon-16x16.png")
    assert html =~ ~s(href="/apple-touch-icon.png")
    refute html =~ "favicon.svg"
    refute html =~ "Settings"
    refute html =~ ~s(id="voice-controller")
    assert html =~ "@#{user.github_login}"

    assert has_element?(
             view,
             "#account-bar-trigger[popovertarget='account-bar-menu'] img[src='#{user.github_avatar_url}']"
           )

    assert has_element?(view, "#account-bar-menu[popover=auto][role=menu]")

    assert has_element?(
             view,
             "#account-bar-menu form[action='/logout'] button[role=menuitem]"
           )

    refute html =~ "CONNECTED / THIS BROWSER"
  end

  test "recording off makes no claim that calls are recorded", %{conn: conn} do
    previous_voice = Application.fetch_env!(:openagents, :voice)
    previous_recording = Application.fetch_env!(:openagents, :voice_recording)
    Application.put_env(:openagents, :voice, enabled_voice())

    Application.put_env(
      :openagents,
      :voice_recording,
      Keyword.put(previous_recording, :enabled, false)
    )

    on_exit(fn ->
      Application.put_env(:openagents, :voice, previous_voice)
      Application.put_env(:openagents, :voice_recording, previous_recording)
    end)

    conn = log_in_github_user(conn, "voice-recording-off-user")
    assert {:ok, _view, html} = live(conn, ~p"/sarah")

    assert html =~ ~s(id="voice-controller")
    assert html =~ ~s(data-recording-enabled="false")
  end

  test "recording on surfaces a disclosure before the voice control", %{conn: conn} do
    previous_voice = Application.fetch_env!(:openagents, :voice)
    previous_recording = Application.fetch_env!(:openagents, :voice_recording)

    Application.put_env(:openagents, :voice, enabled_voice())

    Application.put_env(
      :openagents,
      :voice_recording,
      Keyword.put(previous_recording, :enabled, true)
    )

    on_exit(fn ->
      Application.put_env(:openagents, :voice, previous_voice)
      Application.put_env(:openagents, :voice_recording, previous_recording)
    end)

    conn = log_in_github_user(conn, "voice-recording-on-user")
    assert {:ok, _view, html} = live(conn, ~p"/sarah")

    assert html =~ ~s(id="voice-controller")
    assert html =~ ~s(id="voice-recording-disclosure")
    assert html =~ "readable by a Sarah operator"

    assert html =~
             "#{OpenAgents.Voice.Recordings.config().retention_days} days after a call ends"

    assert html =~ ~s(data-recording-enabled="true")
  end

  test "a connected LiveView refuses events after the account is banned", %{conn: conn} do
    user = github_user("live-ban-user")
    conn = log_in_github_user(conn, "live-ban-user")
    assert {:ok, view, _html} = live(conn, ~p"/sarah")
    assert {:ok, _banned} = OpenAgents.Accounts.ban_user(user, "manual_abuse_review")

    view |> form("#message-form", chat: %{message: "still here?"}) |> render_submit()
    assert_redirect(view, ~p"/")
  end

  test "voice controls project fenced server state and typing closes a runtime-less session honestly",
       %{conn: conn} do
    previous_voice = Application.fetch_env!(:openagents, :voice)
    Application.put_env(:openagents, :voice, enabled_voice())
    on_exit(fn -> Application.put_env(:openagents, :voice, previous_voice) end)

    token = "voice-live-browser-credential-000000000000000000"
    user = github_user(token)
    conn = log_in_github_user(conn, token)
    assert {:ok, view, html} = live(conn, ~p"/sarah")

    assert html =~ ~s(id="voice-controller")
    assert html =~ ~s(phx-hook="VoiceController")
    assert html =~ ~s(data-server-status="idle")
    assert html =~ ~s(id="voice-start")
    assert html =~ ~s(id="voice-mute")
    assert html =~ ~s(id="voice-interrupt")
    assert html =~ ~s(id="voice-end")
    assert html =~ ~s(role="status")
    assert has_element?(view, "#message-form #voice-start[aria-label='Start voice']")
    assert has_element?(view, "#voice-status.visually-hidden")
    refute has_element?(view, "#voice-start", "START VOICE")
    refute has_element?(view, ".voice-state-line")
    # The status line states lifecycle and microphone state; retention is stated
    # once, by the disclosure, and never contradicted here.
    refute html =~ "AUDIO NOT STORED"
    refute html =~ "OPENAI_API_KEY"
    refute html =~ "rtc_"

    conversation = Conversations.get_conversation_for_user(user)
    {:ok, session} = Voice.admit_session(conversation, Voice.Config.current!())

    assert {:ok, listening, _event, :created} =
             Voice.record_provider_event(session, session.generation, %Voice.ProviderEvent{
               kind: :sideband_connected,
               provider_event_id: nil,
               payload: %{}
             })

    assert eventually(fn ->
             rendered = render(view)

             rendered =~ ~s(data-server-status="listening") and
               rendered =~ ~s(data-server-generation="1")
           end)

    view
    |> form("#message-form", chat: %{message: "Continue this in typed chat."})
    |> render_submit()

    assert eventually(fn ->
             rendered = render(view)

             rendered =~ "You said: Continue this in typed chat." and
               rendered =~ ~s(data-server-status="ended")
           end)

    ended = Voice.get_session!(listening.id)
    assert ended.status == "ended"
    assert ended.termination_reason == "voice_runtime_missing"
  end

  test "typing during a live voice call keeps the call open and hands voice the message", %{
    conn: conn
  } do
    previous_voice = Application.fetch_env!(:openagents, :voice)
    Application.put_env(:openagents, :voice, enabled_voice())
    Application.put_env(:openagents, :voice_call_test_observer, self())
    Application.put_env(:openagents, :voice_sideband_test_observer, self())

    on_exit(fn ->
      Application.put_env(:openagents, :voice, previous_voice)
      Application.delete_env(:openagents, :voice_call_test_observer)
      Application.delete_env(:openagents, :voice_sideband_test_observer)
    end)

    token = "voice-typed-inject-credential-00000000000000000"
    user = github_user(token)
    conn = log_in_github_user(conn, token)
    assert {:ok, view, _html} = live(conn, ~p"/sarah")

    conversation = Conversations.get_conversation_for_user(user)

    assert {:ok, session, _admission} =
             OpenAgents.VoiceSessions.connect(
               conversation,
               "v=0\r\no=typed-live-offer",
               String.duplicate("a", 64),
               Voice.Config.current!()
             )

    assert_receive {:sideband_started, _sideband, _sideband_session}

    assert eventually(fn ->
             render(view) =~ ~s(data-server-status="listening")
           end)

    typed = "Read https://github.com/OpenAgentsInc/openagents.com while we talk."

    view
    |> form("#message-form", chat: %{message: typed})
    |> render_submit()

    assert_receive {:sideband_event_sent, %{"type" => "conversation.item.create", "item" => item}}

    assert item["role"] == "user"
    assert [%{"type" => "input_text", "text" => ^typed}] = item["content"]

    assert eventually(fn ->
             rendered = render(view)

             rendered =~ "Read https://github.com/OpenAgentsInc/openagents.com" and
               rendered =~ ~s(data-server-status="listening")
           end)

    # No text turn opened: voice still owns the response chronology.
    refute render(view) =~ "You said: Read"

    stored = Voice.get_session!(session.id)
    assert stored.status == "listening"
    assert {:ok, _ended} = OpenAgents.VoiceSessions.end_session(stored)
  end

  test "live voice transcript deltas render immediately and yield to the durable message", %{
    conn: conn
  } do
    user = github_user("live-voice-delta-browser")
    conn = log_in_github_user(conn, "live-voice-delta-browser")
    assert {:ok, view, _html} = live(conn, ~p"/sarah")
    conversation = Conversations.get_conversation_for_user(user)

    send(view.pid, {
      :voice_live_transcript,
      %{
        voice_session_id: Ecto.UUID.generate(),
        conversation_id: conversation.id,
        item_id: "item-live-1",
        role: "assistant",
        content: "The first"
      }
    })

    assert render(view) =~ "The first"

    send(view.pid, {
      :voice_live_transcript,
      %{
        voice_session_id: Ecto.UUID.generate(),
        conversation_id: conversation.id,
        item_id: "item-live-1",
        role: "assistant",
        content: "The first words."
      }
    })

    html = render(view)
    assert html =~ "The first words."
    assert has_element?(view, "#messages-voice-live-item-live-1")

    durable = %Message{
      id: Ecto.UUID.generate(),
      conversation_id: conversation.id,
      role: "assistant",
      content: "The first words. All of them.",
      status: "complete",
      modality: "voice",
      provider_item_id: "item-live-1",
      transcript_kind: "provider_output_transcript"
    }

    send(view.pid, {:message_updated, durable})

    html = render(view)
    assert html =~ "The first words. All of them."
    refute has_element?(view, "#messages-voice-live-item-live-1")
  end

  test "voice tool activity stays in the ordered transcript after the call ends", %{conn: conn} do
    key = "voice-durable-activity-credential-0000000000000"
    user = github_user(key)
    conn = log_in_github_user(conn, key)
    assert {:ok, opening, _html} = live(conn, ~p"/sarah")
    GenServer.stop(opening.pid)
    conversation = Conversations.get_conversation_for_user(user)
    snapshot = OpenAgents.Tools.Registry.current!()
    config = OpenAgents.Voice.Config.build!(enabled_voice())
    assert {:ok, session} = Voice.admit_session(conversation, config)

    {:ok, session, _event, :created} =
      Voice.record_provider_event(
        session,
        session.generation,
        voice_event(:user_transcript_final, "evt-chat-user", %{
          "item_id" => "item-chat-user",
          "response_id" => nil,
          "content" => "Look through what you remember."
        })
      )

    assert {:ok, context} = Voice.capture_response_context(session, "item-chat-user", snapshot)

    {:ok, session, _event, :created} =
      Voice.record_provider_event(
        session,
        session.generation,
        voice_event(:response_started, "evt-chat-start", %{"response_id" => "response-chat"}),
        response_context: context
      )

    request =
      voice_event(:tool_call_requested, "evt-chat-tool", %{
        "response_id" => "response-chat",
        "item_id" => "item-chat-call",
        "call_id" => "call-chat",
        "tool_name" => "memory_list",
        "raw_arguments" => ~s({"category":"","first":1})
      })

    {:ok, session, _event, :created} =
      Voice.record_provider_event(session, session.generation, request)

    assert {:ok, requested, :created} = Voice.request_tool_step(session, request, snapshot)

    assert {:ok, _refused} =
             Voice.refuse_tool_step(
               session,
               requested,
               "tool_call_limit_reached",
               "This turn reached the host limit of 8 tool calls."
             )

    {:ok, session, _event, :created} =
      Voice.record_provider_event(
        session,
        session.generation,
        voice_event(:assistant_transcript_final, "evt-chat-assistant", %{
          "item_id" => "item-chat-assistant",
          "response_id" => "response-chat",
          "content" => "I stopped short of finishing that."
        })
      )

    {:ok, session, _event, :created} =
      Voice.record_provider_event(
        session,
        session.generation,
        voice_event(:response_completed, "evt-chat-done", %{
          "response_id" => "response-chat",
          "status" => "completed",
          "usage" => %{}
        })
      )

    assert {:ok, _ended} = Voice.end_session(session, session.generation, "test_end")

    assert [receipt] = Voice.list_response_receipts(session)
    assistant_message_id = receipt.assistant_message_id

    assert {:ok, view, html} = live(conn, ~p"/sarah")

    assert html =~ "I stopped short of finishing that."

    # The spoken tool call sits in the assistant row it belongs to, not in the
    # live panel, which a reload empties.
    assert has_element?(view, "#tool-activity-messages-#{assistant_message_id}")
    refute has_element?(view, "#live-tool-activity")

    row = view |> element("#tool-activity-messages-#{assistant_message_id}") |> render()

    assert row =~ "wasn&#39;t permitted"
    assert row =~ "Denied"
    assert row =~ "tool_call_limit_reached"
  end

  test "reload preserves one canonical greeting without fake recognition", %{conn: conn} do
    key = "reload-browser-credential-0000000000000000"
    user = github_user(key)
    conn = log_in_github_user(conn, key)

    assert {:ok, first_view, first_html} = live(conn, ~p"/sarah")
    GenServer.stop(first_view.pid)
    assert {:ok, _second_view, second_html} = live(conn, ~p"/sarah")

    assert first_html =~ "Hello. I&#39;m Sarah—an OpenAgent. What are we working on?"
    assert second_html =~ "Hello. I&#39;m Sarah—an OpenAgent. What are we working on?"
    refute second_html =~ "Welcome back"

    conversation = Conversations.get_conversation_for_user(user)

    greeting_count =
      OpenAgents.Repo.aggregate(
        from(m in Message,
          where:
            m.conversation_id == ^conversation.id and m.role == "assistant" and
              m.content == "Hello. I'm Sarah—an OpenAgent. What are we working on?"
        ),
        :count
      )

    assert greeting_count == 1
  end

  test "turn execution receives the installed Sarah persona and default role", %{conn: conn} do
    conn = log_in_github_user(conn, "persona-browser")
    assert {:ok, view, _html} = live(conn, ~p"/sarah")

    view
    |> form("#message-form", chat: %{message: "[inspect-persona]"})
    |> render_submit()

    assert eventually(fn ->
             html = render(view)
             html =~ "Sarah persona and role received." and not (html =~ ~s(id="cancel-turn"))
           end)
  end

  test "sends, streams, and durably stores a complete turn", %{conn: conn} do
    user = github_user("durable-turn-user")
    conn = log_in_github_user(conn, "durable-turn-user")
    assert {:ok, view, _html} = live(conn, ~p"/sarah")
    conversation = Conversations.get_conversation_for_user(user)

    view
    |> form("#message-form", chat: %{message: "Help me reason about this."})
    |> render_submit()

    assert eventually(fn ->
             html = render(view)

             # The composer is usable again: the send control is a glyph now, so
             # this asserts the control's state rather than a visible word.
             html =~ "You said: Help me reason about this." and
               html =~ ~s(id="send-message") and
               not (html =~ ~s(id="send-message" disabled)) and
               not (html =~ ~s(id="cancel-turn"))
           end)

    # Asymmetry carries the roles (DESIGN.md, Message row): the person's
    # message is the tinted bubble, Sarah's stays bare prose with no bubble
    # and no avatar column. The bubble is AI Elements' now — `message/1` marks
    # the row `is-user` and `message_content/1`'s `group-[.is-user]:` rules
    # paint it — so the marker is what this asserts, not the retired
    # `.message-bubble` class the marker replaced.
    assert has_element?(view, ~s(.message-row--user[data-from="user"].is-user .message-content))
    assert has_element?(view, ".message-row--assistant.is-assistant .message-content")
    refute has_element?(view, ".message-row--assistant.is-user")
    refute has_element?(view, ".message-bubble")
    refute has_element?(view, ".message-row .avatar")

    persisted =
      OpenAgents.Repo.all(
        from(m in Message,
          where: m.conversation_id == ^conversation.id,
          order_by: [asc: m.inserted_at, asc: m.id]
        )
      )

    assert Enum.any?(
             persisted,
             &(&1.role == "user" and &1.content == "Help me reason about this.")
           )

    assert Enum.any?(persisted, fn message ->
             message.role == "assistant" and
               message.status == "complete" and
               message.content =~ "You said: Help me reason about this."
           end)
  end

  test "each message carries a labeled hover toolbar and an accessible timestamp", %{conn: conn} do
    conn = log_in_github_user(conn, "toolbar-browser-credential-000000000000000000000")
    assert {:ok, view, html} = live(conn, ~p"/sarah")

    view
    |> form("#message-form", chat: %{message: "Toolbar please."})
    |> render_submit()

    assert eventually(fn ->
             html = render(view)
             html =~ "You said: Toolbar please." and not (html =~ ~s(id="cancel-turn"))
           end)

    # Copy actions are icon-only, so the accessible name lives on the control.
    assert has_element?(
             view,
             ~s(.message-row--user .message-toolbar button[data-copy-kind="message"][aria-label="Copy message"])
           )

    assert has_element?(
             view,
             ~s(.message-row--user .message-toolbar button[data-copy-kind="link"][aria-label="Copy message link"])
           )

    assert has_element?(
             view,
             ~s(.message-row--assistant .message-toolbar button[data-copy-kind="message"])
           )

    # The timestamp is hover-revealed visually but always in the tree.
    assert has_element?(view, ".message-row--user .message-toolbar time[datetime]")

    # The code-copy pill ships from the template with its vendored glyph and
    # accessible name; the transcript hook clones it into rendered wells.
    assert html =~ ~s(id="code-copy-template")
    assert html =~ ~s(aria-label="Copy code")
  end

  test "reload reconstructs bounded tool activity without provider identifiers", %{conn: conn} do
    token = "tool-reload-browser-credential-0000000000000000"
    %{turn: turn, step: step} = begin_tool_turn(token, "durable-query-marker")
    assert {:ok, _running_step, :started} = Conversations.start_tool_step(step)

    conn = log_in_github_user(conn, token)
    assert {:ok, view, html} = live(conn, ~p"/sarah")

    # The event header renders the durable scrubbed values: the subject
    # sentence plus the bounded argument excerpt, per issue #79.
    assert html =~ ~s(id="tool-activity-step-#{step.id}")
    assert html =~ "Working on a look back through this conversation"
    assert html =~ "query=durable-query-marker"
    assert html =~ ~s(role="status")
    assert html =~ ~s(aria-live="polite")
    refute html =~ step.provider_call_id
    refute html =~ step.provider_item_id
    refute html =~ step.provider_response_id

    # The disclosure anatomy: the step is an AI Elements `tool/1`, so the
    # control is a native `<summary>` inside a closed `<details>` rather than
    # a button carrying `aria-expanded`. The browser supplies the disclosure
    # semantics and the keyboard operation; the server writes the initial
    # state, and a stream re-insert closes the row again.
    assert has_element?(view, "details#tool-activity-step-#{step.id}:not([open]) > summary")
    assert has_element?(view, "#tool-activity-step-#{step.id} summary", "Running")
    assert has_element?(view, "#tool-activity-step-#{step.id}-details")
    assert html =~ "ARGUMENTS"

    step_id = step.id
    assigns = :sys.get_state(view.pid).socket.assigns
    assert [%{id: ^step_id, status: "running"} = activity] = assigns.tool_activity
    refute Map.has_key?(activity, :provider_call_id)
    refute Map.has_key?(activity, :provider_item_id)
    refute Map.has_key?(activity, :provider_response_id)

    assert {:ok, _cancelled_turn} = Conversations.cancel_turn(turn)
  end

  test "activity shows actual terminal status and executor then clears with the turn", %{
    conn: conn
  } do
    token = "tool-terminal-browser-credential-00000000000000"
    %{turn: turn, receipt: receipt, step: step} = begin_tool_turn(token, "bounded-query")

    conn = log_in_github_user(conn, token)
    assert {:ok, view, _html} = live(conn, ~p"/sarah")
    assert render(view) =~ "Getting ready for a look back through this conversation"

    assert {:ok, _running_step, :started} = Conversations.start_tool_step(step)

    assert eventually(fn ->
             render(view) =~ "Working on a look back through this conversation"
           end)

    assert {:ok, _completed_step} =
             Conversations.complete_tool_step(
               step,
               tool_outcome(step, "refused", "policy_refused")
             )

    assert eventually(fn ->
             html = render(view)

             html =~ "A look back through this conversation wasn&#39;t permitted" and
               html =~ "EXECUTOR / Sarah policy worker"
           end)

    # The executor disclosure's home is the expansion, not the collapsed row
    # (issue #79): it renders verbatim inside the step's details region.
    assert has_element?(
             view,
             "#tool-activity-step-#{step.id}-details",
             "EXECUTOR / Sarah policy worker"
           )

    assert {:ok, succeeded_step, :created} =
             request_tool_step(turn, receipt, "succeeded-query")

    assert {:ok, _succeeded_step} =
             Conversations.complete_tool_step(succeeded_step, succeeded_outcome(succeeded_step))

    assert {:ok, timed_out_step, :created} =
             request_tool_step(turn, receipt, "timed-out-query")

    assert {:ok, _timed_out_step} =
             Conversations.complete_tool_step(
               timed_out_step,
               tool_outcome(timed_out_step, "failed", "timeout")
             )

    assert eventually(fn ->
             html = render(view)

             # The composer no longer blocks during a turn — Stop signals the
             # active turn; the input stays usable for queuing.
             html =~ "Finished a look back through this conversation" and
               html =~ "Couldn&#39;t finish a look back through this conversation" and
               has_element?(view, "#cancel-turn") and
               has_element?(view, "#chat_message:not([disabled])")
           end)

    assert {:ok, _failed_turn} = Conversations.fail_turn(turn, :test_terminal)

    assert eventually(fn ->
             html = render(view)

             # Activity is part of the transcript now, so it outlives the turn
             # that produced it instead of vanishing with the composer band.
             html =~ ~s(id="tool-activity-step-) and
               not (html =~ ~s(id="cancel-turn")) and
               has_element?(view, "#chat_message:not([disabled])")
           end)
  end

  test "cancel remains usable during slow tool execution and keeps activity", %{conn: conn} do
    Application.put_env(:openagents, :test_tool_observer, self())
    on_exit(fn -> Application.delete_env(:openagents, :test_tool_observer) end)

    conn = log_in_github_user(conn, "tool-cancel-live-browser-credential-000000000000")

    assert {:ok, view, _html} = live(conn, ~p"/sarah")

    view
    |> form("#message-form", chat: %{message: "[cancel-tool-loop]"})
    |> render_submit()

    assert_receive {:test_tool_executed, _tool_pid, "block", _scope_ref}, 1_000

    assert eventually(fn ->
             render(view) =~ "Working on a look back through this conversation"
           end)

    assert has_element?(view, "#cancel-turn")

    view |> element("#cancel-turn") |> render_click()

    assert eventually(fn ->
             html = render(view)

             # Activity is part of the transcript now, so it outlives the turn
             # that produced it instead of vanishing with the composer band.
             html =~ ~s(id="tool-activity-step-) and
               not (html =~ ~s(id="cancel-turn")) and
               has_element?(view, "#chat_message:not([disabled])")
           end)
  end

  test "the composer never blocks: a message sent during a turn queues and runs next", %{
    conn: conn
  } do
    Application.put_env(:openagents, :test_tool_observer, self())
    on_exit(fn -> Application.delete_env(:openagents, :test_tool_observer) end)

    conn = log_in_github_user(conn, "queue-live-browser-credential-00000000000000")
    assert {:ok, view, _html} = live(conn, ~p"/sarah")

    # Start a turn that stays active (its tool blocks).
    view |> form("#message-form", chat: %{message: "[cancel-tool-loop]"}) |> render_submit()
    assert_receive {:test_tool_executed, _tool_pid, "block", _scope_ref}, 1_000

    # The composer is NOT disabled during the turn (the fix) and Stop is present.
    assert has_element?(view, "#chat_message:not([disabled])")
    assert has_element?(view, "#cancel-turn")

    # Sending another message queues it rather than erroring with turn-in-progress.
    view
    |> form("#message-form", chat: %{message: "Help me reason about this."})
    |> render_submit()

    assert has_element?(view, "#message-queue", "Help me reason about this.")
    refute render(view) =~ "still responding"

    # Cancelling the running turn starts the queued message, which runs to
    # completion and drains the queue.
    view |> element("#cancel-turn") |> render_click()

    assert eventually(fn ->
             render(view) =~ "You said: Help me reason about this."
           end)

    refute has_element?(view, "#message-queue")
  end

  test "a second browser cannot render or read the first browser's adversarial source ID", %{
    conn: conn
  } do
    foreign_token = "live-foreign-source-browser-credential-0000000000"
    local_token = "live-local-source-browser-credential-000000000000"
    assert {:ok, foreign} = Conversations.ensure_conversation(github_user(foreign_token))

    source =
      OpenAgents.Repo.insert!(%Message{
        conversation_id: foreign.id,
        role: "user",
        content: "private-live-boundary-marker-73",
        status: "complete"
      })

    Application.put_env(:openagents, :test_foreign_source_ref, "message:#{source.id}")
    on_exit(fn -> Application.delete_env(:openagents, :test_foreign_source_ref) end)

    local_user = github_user(local_token)
    local_conn = log_in_github_user(conn, local_token)
    assert {:ok, local_view, local_html} = live(local_conn, ~p"/sarah")
    refute local_html =~ "private-live-boundary-marker-73"

    local_view
    |> form("#message-form", chat: %{message: "[foreign-source-read]"})
    |> render_submit()

    assert eventually(fn ->
             html = render(local_view)

             html =~ "Recall source outcome: failed." and
               not (html =~ "private-live-boundary-marker-73")
           end)

    # The attempted source ref is this account's own durable argument truth and
    # may render inside the step's activity details (issue #79). The foreign
    # content — and the foreign ID as message content — must never render.
    contents =
      render(local_view)
      |> LazyHTML.from_fragment()
      |> LazyHTML.filter(".message-content")
      |> LazyHTML.text()

    refute contents =~ source.id
    refute contents =~ "private-live-boundary-marker-73"

    local = Conversations.get_conversation_for_user(local_user)

    [turn] =
      OpenAgents.Repo.all(
        from(turn in OpenAgents.Conversations.Turn, where: turn.conversation_id == ^local.id)
      )

    [step] =
      OpenAgents.Repo.all(
        from(step in OpenAgents.Conversations.ToolStep, where: step.turn_id == ^turn.id)
      )

    assert step.status == "failed"
    assert step.error["code"] == "not_found"
    assert step.target_receipt_refs == []
  end

  test "a rejected memory secret reaches no LiveView assign, provider input, or turn receipt", %{
    conn: conn
  } do
    token = "profile-policy-live-browser-credential-000000000000"
    secret = "sk-" <> "proj-LIVEPRIVATEVALUE12345678901234567890"
    assert {:ok, conversation} = Conversations.ensure_conversation(github_user(token))
    owner = OpenAgents.Repo.get!(Visitor, conversation.visitor_id)

    assert {:error, {:memory_policy_rejected, "api_token"}} =
             ProfileMemory.create_candidate(owner, %{
               category: "preference",
               claim: secret,
               creator: "user_explicit",
               provenance: %{"intent" => "remember"},
               owner_asserted: true,
               sources: []
             })

    Application.put_env(:openagents, :test_provider_observer, self())
    on_exit(fn -> Application.delete_env(:openagents, :test_provider_observer) end)

    conn = log_in_github_user(conn, token)
    assert {:ok, view, html} = live(conn, ~p"/sarah")
    refute html =~ secret
    refute inspect(:sys.get_state(view.pid).socket.assigns) =~ secret

    view
    |> form("#message-form", chat: %{message: "[observe-request]"})
    |> render_submit()

    assert_receive {:provider_request, provider_pid, request}, 1_000
    refute inspect(request) =~ secret
    send(provider_pid, :continue_provider)

    assert eventually(fn -> render(view) =~ "Observed model" end)
    refute render(view) =~ secret
    refute inspect(:sys.get_state(view.pid).socket.assigns) =~ secret

    turn =
      OpenAgents.Repo.one!(
        from(turn in OpenAgents.Conversations.Turn,
          where: turn.conversation_id == ^conversation.id,
          order_by: [desc: turn.inserted_at],
          limit: 1
        )
      )

    assert {:ok, receipt} = Conversations.get_turn_receipt(turn)
    refute inspect(receipt) =~ secret
  end

  test "bounded export uses safe account-scoped public projections", %{conn: conn} do
    token = "memory-export-browser-credential-000000000000000"
    %{record: record} = create_profile_memory(token, "My project is One", "project")
    conn = log_in_github_user(conn, token)
    response = get(conn, ~p"/memory/export")

    assert response.status == 200
    assert get_resp_header(response, "content-type") |> List.first() =~ "application/json"

    assert get_resp_header(response, "content-disposition") == [
             ~s(attachment; filename="sarah-memory-account.json")
           ]

    export = Jason.decode!(response.resp_body)
    assert export["schema"] == "sarah.profile_memory_account_export.v1"
    assert export["scope"] == "authenticated_github_user"

    assert [%{"id" => id, "claim" => "My project is One", "projection" => "admitted"}] =
             export["records"]

    assert id == record.id
    refute response.resp_body =~ "tool_outputs"
    refute response.resp_body =~ "instructions"
    refute response.resp_body =~ "INTERNAL_TEST_PAYLOAD"
  end

  # ── The AI Elements composition ─────────────────────────────────────────────
  # The surface is built from the ported components rather than from bespoke
  # chat CSS. What follows checks the composition itself: that the parts are
  # the ported ones, and that the state each of them needs from the LiveView
  # actually reaches it. A screenshot cannot tell a `div` that looks like a
  # transcript from one the scroll hook can follow.

  test "the transcript is a conversation of messages, not a hand-rolled scroller", %{conn: conn} do
    conn = log_in_github_user(conn, "conversation-composition-browser-00000000")
    {:ok, view, _html} = live(conn, ~p"/sarah")

    # The scroller is `conversation/1`: a log region whose inner box scrolls,
    # so the return-to-newest control can hold still against the bottom edge.
    assert has_element?(view, "#conversation[role=log]")
    assert has_element?(view, ~s(#conversation-viewport[data-conversation-viewport="true"]))
    assert has_element?(view, ~s(#conversation-scroll-button[data-conversation-scroll-button]))

    # `.TranscriptScroll` wraps it rather than replacing it: the AI Elements
    # hook pins to the newest turn, and this one keeps the copied-link anchor,
    # the prepend position, and the server's scroll event.
    assert has_element?(view, ~s(#transcript[phx-hook$=".TranscriptScroll"] #conversation))

    # The turns are `conversation_content/1` holding the stream.
    assert has_element?(view, "#conversation #conversation-content.message-list")
    assert has_element?(view, ~s(#conversation-content #messages[phx-update="stream"]))

    # Each turn is `message/1` carrying the marker `message_content/1` reads.
    assert has_element?(view, ~s(#messages [data-from="assistant"].is-assistant))
    assert has_element?(view, ~s([data-from="assistant"] .message-content))
    assert has_element?(view, ~s([data-from="assistant"] .message-toolbar))
  end

  test "the composer is a prompt input that states its submit status", %{conn: conn} do
    Application.put_env(:openagents, :test_tool_observer, self())
    on_exit(fn -> Application.delete_env(:openagents, :test_tool_observer) end)

    conn = log_in_github_user(conn, "prompt-input-composition-browser-000000")
    {:ok, view, _html} = live(conn, ~p"/sarah")

    # The form is `prompt_input/1`: its hook owns Enter-to-submit and the
    # auto-resize the retired `.Composer` hook used to do by hand.
    assert has_element?(view, ~s(form#message-form[phx-hook$=".PromptInput"]))
    assert has_element?(view, ~s(#message-form[data-submit-on-enter="true"]))

    # The control is `prompt_input_textarea/1`, which is what makes it the
    # input group's control rather than a textarea that happens to be inside.
    assert has_element?(view, ~s(#chat_message[data-slot="input-group-control"]))
    assert has_element?(view, ~s(#message-form [data-slot="input-group"] #chat_message))

    # Idle: the send control is a submit button in the ready state.
    assert has_element?(view, ~s(#send-message[type="submit"][data-status="ready"]))

    view |> form("#message-form", chat: %{message: "[cancel-tool-loop]"}) |> render_submit()
    assert_receive {:test_tool_executed, _tool_pid, "block", _scope_ref}, 1_000

    # A turn is in flight: the status says so, and the control still submits,
    # because a message sent now queues rather than being refused.
    assert has_element?(view, ~s(#send-message[type="submit"][data-status="submitted"]))
    assert has_element?(view, ~s(#send-message[aria-label="Queue message"]))
    assert has_element?(view, "#message-form #cancel-turn")

    view |> element("#cancel-turn") |> render_click()

    assert eventually(fn -> has_element?(view, ~s(#send-message[data-status="ready"])) end)
  end

  test "queued messages are a queue, and each row still drops itself", %{conn: conn} do
    Application.put_env(:openagents, :test_tool_observer, self())
    on_exit(fn -> Application.delete_env(:openagents, :test_tool_observer) end)

    conn = log_in_github_user(conn, "queue-composition-browser-0000000000000")
    {:ok, view, _html} = live(conn, ~p"/sarah")

    view |> form("#message-form", chat: %{message: "[cancel-tool-loop]"}) |> render_submit()
    assert_receive {:test_tool_executed, _tool_pid, "block", _scope_ref}, 1_000

    view |> form("#message-form", chat: %{message: "Wait your turn."}) |> render_submit()

    # `queue/1` holding one `queue_section/1`, which is a native details so the
    # disclosure works before any JavaScript loads.
    assert has_element?(view, ~s(#message-queue[data-slot="queue"]))
    assert has_element?(view, "#message-queue details#message-queue-section[open] > summary")
    assert has_element?(view, "#message-queue-section summary", "1 QUEUED")

    # One `queue_item/1` per waiting message, still carrying its own control.
    assert has_element?(view, "#message-queue li[id^=queued-]", "Wait your turn.")

    assert has_element?(
             view,
             ~s(#message-queue button[phx-click="dequeue_message"][aria-label="Remove queued message"])
           )

    view
    |> element(~s(#message-queue button[phx-click="dequeue_message"]))
    |> render_click()

    refute has_element?(view, "#message-queue")
  end

  test "a tool call is a tool block whose badge states the step's real state", %{conn: conn} do
    token = "tool-composition-browser-credential-000000000000"
    %{turn: turn, step: step} = begin_tool_turn(token, "composition-query")

    conn = log_in_github_user(conn, token)
    {:ok, view, _html} = live(conn, ~p"/sarah")

    # Requested, before the step starts: `tool_status_badge/1` reads
    # `input-streaming`, which it labels Pending.
    assert has_element?(view, "details#tool-activity-step-#{step.id} > summary", "Pending")

    assert {:ok, _running_step, :started} = Conversations.start_tool_step(step)

    assert eventually(fn ->
             has_element?(view, "#tool-activity-step-#{step.id} summary", "Running")
           end)

    assert {:ok, _refused_step} =
             Conversations.complete_tool_step(
               step,
               tool_outcome(step, "refused", "policy_refused")
             )

    # A refusal is `output-denied`, not a generic error: the badge says Denied,
    # and the expansion still states the exact durable status word.
    assert eventually(fn ->
             has_element?(view, "#tool-activity-step-#{step.id} summary", "Denied") and
               has_element?(view, "#tool-activity-step-#{step.id}-details", "refused")
           end)

    assert {:ok, _cancelled_turn} = Conversations.cancel_turn(turn)
  end

  defp begin_tool_turn(token, query) do
    assert {:ok, conversation} = Conversations.ensure_conversation(github_user(token))
    assert {:ok, records} = Conversations.create_turn(conversation, "Recall something.")
    context = Composer.compose!()

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

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

    assert {:ok, step, :created} = request_tool_step(inference.turn, inference.receipt, query)

    %{turn: inference.turn, receipt: inference.receipt, step: step}
  end

  defp create_profile_memory(token, claim, category) do
    assert {:ok, conversation} = Conversations.ensure_conversation(github_user(token))
    owner = Conversations.get_conversation_owner!(conversation)

    source =
      OpenAgents.Repo.insert!(%Message{
        conversation_id: conversation.id,
        role: "user",
        content: "Remember that #{claim}",
        status: "complete"
      })

    assert {:ok, %{record: record}} =
             ProfileMemory.remember_explicit(owner, %{
               category: category,
               claim: claim,
               creator: "user_explicit",
               provenance: %{
                 "operation" => "test_explicit_memory",
                 "tool_payload" => "INTERNAL_TEST_PAYLOAD"
               },
               sources: [%{source_ref: "message:#{source.id}", kind: "owner_statement"}]
             })

    %{record: record, source: source, owner: owner}
  end

  defp request_tool_step(turn, receipt, query) do
    artifact =
      Map.fetch!(OpenAgents.Tools.Registry.current!().modules, {"sarah.tool.recall_messages", 1})

    routing_receipt = routing_receipt!(receipt, "call-ui-#{query}", artifact)
    policy = artifact.attribution_policy

    Conversations.request_tool_step(turn, receipt, %{
      provider_call_id: "call-ui-#{query}",
      provider_item_id: "item-ui-#{query}",
      provider_response_id: "response-ui-#{query}",
      tool_name: "recall_messages",
      tool_version: 1,
      module_id: "sarah.tool.recall_messages",
      module_artifact_digest: artifact.artifact_digest,
      executor_implementation_digest: artifact.implementation_digest,
      routing_receipt_id: routing_receipt.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: Jason.encode!(%{"query" => query})
    })
  end

  defp routing_receipt!(receipt, call_id, artifact) do
    snapshot = OpenAgents.Tools.Registry.current!()
    policy = OpenAgents.Modules.RoutingPolicy.default()

    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, policy, %{
               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 succeeded_outcome(step) do
    tool_outcome(step, "succeeded", nil)
    |> Map.put("result", %{"matches" => []})
    |> Map.put("error", nil)
  end

  defp tool_outcome(step, status, code) do
    now = DateTime.utc_now() |> DateTime.to_iso8601()

    %{
      "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.policy.worker",
        "disclosure" => "Sarah policy worker",
        "implementation_digest" => step.executor_implementation_digest
      },
      "status" => status,
      "result" => nil,
      "error" => %{"code" => code, "message" => "The capability did not complete."},
      "target_receipt_refs" => [],
      "attribution_refs" => [],
      "started_at" => now,
      "completed_at" => now
    }
  end

  defp eventually(assertion, timeout \\ 1_000) do
    deadline = System.monotonic_time(:millisecond) + timeout
    do_eventually(assertion, deadline)
  end

  defp voice_event(kind, event_id, payload) do
    %OpenAgents.Voice.ProviderEvent{
      kind: kind,
      provider_event_id: event_id,
      payload: payload
    }
  end

  defp enabled_voice do
    [
      enabled: true,
      architecture: :openai_realtime,
      provider: "openai",
      model: "gpt-realtime-2.1",
      voice: "marin",
      reasoning_effort: "low",
      maximum_session_seconds: 3_000
    ]
  end

  defp do_eventually(assertion, deadline) do
    if assertion.() do
      true
    else
      if System.monotonic_time(:millisecond) >= deadline do
        false
      else
        receive do
          _message -> :ok
        after
          10 -> :ok
        end

        do_eventually(assertion, deadline)
      end
    end
  end
end