The operator surface lists accounts, not voice calls

5716ca08edb7 · AtlantisPleb · · parent cee26598af17

The operator surface lists accounts, not voice calls

The panel's only view was of voice-call recordings. Recording is off, the
product does not capture call audio, and an operator surface whose single view
is of a capability that does not run describes the system inaccurately to the
one person who most needs an accurate picture of it.

It now lists accounts, newest first: who signed up, when, when they were last
seen, how many messages and issues they have, and whether they are active or
banned. A table rather than cards, because the question this surface answers
is comparative and cards would put each account in its own box and make the
columns impossible to scan down.

`Admin.list_accounts/1` is one query with left joins rather than one query per
account: an N+1 is fine with three accounts and unusable with three thousand,
and the joins mean an account with no activity is listed with zeroes instead
of dropped.

Counts, never content. Knowing that someone sent forty messages is an
operational fact; reading them is a different decision with a different
implementation, and there is no route to it from here. A test asserts the
private text of a message does not appear on the page.

Still read-only: `OpenAgents.Admin` exposes no write, so nothing here can ban
an account or alter a conversation, and operator status is re-checked per
event because a long-lived socket outlives the decision that opened it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149rBWy7br1Z7bbz9NrQhEr
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified assets/css/openagents.css
  • modified lib/openagents_web/live/admin_live.ex
  • modified test/openagents_web/live/admin_live_test.exs

Diff

3 files changed, +248 -305

assets/css/openagents.css modified +98

@@ -4496,3 +4496,101 @@

4496 4496
    text-wrap: pretty;
4497 4497
  }
4498 4498
}
4499
4500
/* ── Operator ─────────────────────────────────────────────────────────────── */
4501
4502
/* A table, because the question this surface answers is comparative: who
4503
 * signed up, when, and how much have they done. Cards would put each account
4504
 * in its own box and make the columns impossible to scan down. */
4505
4506
@layer components {
4507
  .admin-count {
4508
    color: var(--text-dim);
4509
    font-size: 0.8125rem;
4510
    font-variant-numeric: tabular-nums;
4511
  }
4512
4513
  /* The table scrolls inside its own container rather than widening the page:
4514
     a horizontal scrollbar on the document is a layout failure, on a table it
4515
     is the design. */
4516
  .admin-table-scroll {
4517
    overflow-x: auto;
4518
    margin-inline: -16px;
4519
    padding-inline: 16px;
4520
  }
4521
4522
  .admin-table {
4523
    width: 100%;
4524
    border-collapse: collapse;
4525
    font-size: 0.875rem;
4526
  }
4527
4528
  .admin-table th {
4529
    padding: 8px 12px 8px 0;
4530
    border-bottom: 1px solid var(--line);
4531
    color: var(--text-dim);
4532
    font-size: 0.75rem;
4533
    font-weight: 500;
4534
    letter-spacing: 0.04em;
4535
    text-align: start;
4536
    text-transform: uppercase;
4537
    white-space: nowrap;
4538
  }
4539
4540
  .admin-table td {
4541
    padding: 10px 12px 10px 0;
4542
    border-bottom: 1px solid var(--line-faint);
4543
    color: var(--text-muted);
4544
    vertical-align: middle;
4545
    white-space: nowrap;
4546
  }
4547
4548
  .admin-table tr:last-child td {
4549
    border-bottom: 0;
4550
  }
4551
4552
  /* Counts line up on their digits so a column can be compared by eye. */
4553
  .admin-table__number {
4554
    text-align: end;
4555
    font-variant-numeric: tabular-nums;
4556
  }
4557
4558
  .admin-identity {
4559
    display: flex;
4560
    align-items: center;
4561
    gap: 8px;
4562
  }
4563
4564
  .admin-identity__names {
4565
    display: flex;
4566
    min-width: 0;
4567
    flex-direction: column;
4568
  }
4569
4570
  .admin-identity__names strong {
4571
    color: var(--text-primary);
4572
    font-size: 0.875rem;
4573
    font-weight: 500;
4574
    line-height: 1.25rem;
4575
  }
4576
4577
  .admin-identity__names span {
4578
    color: var(--text-dim);
4579
    font-size: 0.75rem;
4580
    line-height: 1rem;
4581
  }
4582
4583
  .admin-paging {
4584
    display: flex;
4585
    align-items: center;
4586
    justify-content: space-between;
4587
    gap: 12px;
4588
    padding-block-start: 4px;
4589
  }
4590
4591
  .admin-paging__position {
4592
    color: var(--text-dim);
4593
    font-size: 0.8125rem;
4594
    font-variant-numeric: tabular-nums;
4595
  }
4596
}
lib/openagents_web/live/admin_live.ex modified +105 -186

@@ -1,27 +1,28 @@

1 1
defmodule OpenAgentsWeb.AdminLive do
2 2
  @moduledoc """
3
  The operator surface: every voice call, newest first, with bounded recording
4
  metadata.
3
  The operator surface: every account, newest first, with what each has done here.
5 4
6
  Read-only by construction. `OpenAgents.Admin` exposes no write, so nothing here can
7
  ban an account, alter a conversation, or change configuration
8
  (`INVARIANTS.md` ADMIN-001). The only actions are paging and playback.
5
  Read-only by construction. `OpenAgents.Admin` exposes no write, so nothing
6
  here can ban an account, alter a conversation, or change configuration
7
  (`INVARIANTS.md` ADMIN-001). The only action is paging.
9 8
10
  Two presentation rules are deliberate:
9
  This lists accounts and activity counts, never content. Knowing that someone
10
  sent forty messages is an operational fact; reading them is a different
11
  decision with a different implementation, and there is no route to it from
12
  here.
11 13
12
    * Calls with no uploaded recording are listed with the reason. A panel that
13
      hid them would look like an empty history instead of an honest one.
14
    * Transcript *content* never appears, only whether a transcript exists. The
15
      operator has no recording-download route either; cross-account content
16
      access requires a separate decision and implementation.
14
  It replaced a voice-call recordings panel. Recording was off, the product no
15
  longer captures call audio, and an operator surface whose only view is of a
16
  capability that does not run is worse than no surface: it describes the
17
  system inaccurately to the one person who most needs an accurate picture.
17 18
  """
18 19
19 20
  use OpenAgentsWeb, :live_view
20 21
21 22
  alias OpenAgents.Accounts
22 23
  alias OpenAgents.Admin
23
  alias OpenAgents.Admin.Call
24
  alias OpenAgents.Voice.Recordings
24
25
  @page_size 50
25 26
26 27
  @impl true
27 28
  def mount(_params, _session, socket) do

@@ -30,7 +31,6 @@ defmodule OpenAgentsWeb.AdminLive do

30 31
       socket
31 32
       |> assign(:page_title, "Operator · OpenAgents")
32 33
       |> assign(:offset, 0)
33
       |> assign(:recording_config, Recordings.config())
34 34
       |> load_page()}
35 35
    else
36 36
      {:ok, redirect(socket, to: ~p"/")}

@@ -39,6 +39,8 @@ defmodule OpenAgentsWeb.AdminLive do

39 39
40 40
  @impl true
41 41
  def handle_event(event, _params, socket) when event in ["next_page", "previous_page"] do
42
    # Re-checked per event, not only at mount: a long-lived socket outlives the
43
    # decision that opened it.
42 44
    if Accounts.admin?(socket.assigns.current_user) do
43 45
      do_handle_event(event, socket)
44 46
    else

@@ -46,205 +48,122 @@ defmodule OpenAgentsWeb.AdminLive do

46 48
    end
47 49
  end
48 50
49
  def do_handle_event("next_page", socket) do
50
    offset = socket.assigns.offset + page_size()
51
  defp do_handle_event("next_page", socket) do
52
    offset = socket.assigns.offset + @page_size
51 53
52
    if offset < socket.assigns.totals.calls,
54
    if offset < socket.assigns.total,
53 55
      do: {:noreply, socket |> assign(:offset, offset) |> load_page()},
54 56
      else: {:noreply, socket}
55 57
  end
56 58
57
  def do_handle_event("previous_page", socket) do
58
    offset = max(socket.assigns.offset - page_size(), 0)
59
  defp do_handle_event("previous_page", socket) do
60
    offset = max(socket.assigns.offset - @page_size, 0)
59 61
    {:noreply, socket |> assign(:offset, offset) |> load_page()}
60 62
  end
61 63
62 64
  defp load_page(socket) do
63 65
    socket
64
    |> assign(:totals, Admin.recording_totals())
65
    |> assign(:calls, Admin.list_calls(limit: page_size(), offset: socket.assigns.offset))
66
    |> assign(:total, Admin.count_accounts())
67
    |> assign(:accounts, Admin.list_accounts(limit: @page_size, offset: socket.assigns.offset))
66 68
  end
67 69
68 70
  @impl true
69 71
  def render(assigns) do
70 72
    ~H"""
71
    <Layouts.app flash={@flash} current_scope={@current_scope} title="Admin">
72
      <main id="admin-page" class="app-shell admin-shell">
73
        <%!-- The same bar every other surface renders, so moving between them
74
              reads as one application. The lockup carries only the way back:
75
              nothing in the product links here, and this is not a place to
76
              navigate onward from. --%>
77
78
        <section class="admin" aria-label="Voice call recordings">
79
          <header class="admin-heading">
80
            <h1>Voice calls</h1>
81
            <p>
82
              Every call across every account, newest first. Audio is captured by the
83
              caller's browser and stored {sealed_label(@recording_config)}; it is
84
              deleted {@recording_config.retention_days} days after a call ends.
85
            </p>
86
            <div class="admin-totals">
87
              <.badge variant={:dim}>{@totals.calls} calls</.badge>
88
              <.badge variant={:info}>{@totals.recorded} with audio</.badge>
89
              <.badge variant={:dim}>{format_bytes(@totals.byte_size)} stored</.badge>
90
              <.badge :if={!@recording_config.enabled?} variant={:warning}>
91
                RECORDING OFF
92
              </.badge>
93
            </div>
94
          </header>
95
96
          <.empty :if={@calls == []} id="admin-empty" title="No voice calls yet">
97
            Calls appear here as soon as anyone talks with OpenAgents.
98
          </.empty>
99
100
          <ol :if={@calls != []} id="admin-calls" class="admin-rows">
101
            <li :for={call <- @calls} id={"admin-call-#{call.session_id}"}>
102
              <.card>
103
                <div class="admin-row">
73
    <Layouts.app flash={@flash} current_scope={@current_scope} title="Admin" wide>
74
      <section class="panel" aria-label="Accounts">
75
        <header class="panel__header">
76
          <h1 class="panel__title">Accounts</h1>
77
          <span class="admin-count">{@total} total</span>
78
        </header>
79
80
        <.empty :if={@accounts == []} id="admin-empty" title="No accounts yet">
81
          Accounts appear here after their first sign-in.
82
        </.empty>
83
84
        <div :if={@accounts != []} class="admin-table-scroll">
85
          <table id="admin-accounts" class="admin-table">
86
            <thead>
87
              <tr>
88
                <th scope="col">Account</th>
89
                <th scope="col">Joined</th>
90
                <th scope="col">Last seen</th>
91
                <th scope="col" class="admin-table__number">Messages</th>
92
                <th scope="col" class="admin-table__number">Issues</th>
93
                <th scope="col">Status</th>
94
              </tr>
95
            </thead>
96
            <tbody>
97
              <tr :for={account <- @accounts} id={"admin-account-#{account.id}"}>
98
                <td>
104 99
                  <div class="admin-identity">
105 100
                    <.avatar
106
                      src={call.github_avatar_url}
101
                      src={account.github_avatar_url}
107 102
                      alt=""
108 103
                      size={:sm}
109
                      fallback={String.first(call.github_login)}
104
                      fallback={String.first(account.github_login)}
110 105
                    />
111
                    <span>
112
                      <strong :if={Call.display_name(call)}>{Call.display_name(call)}</strong>
113
                      <span>@{call.github_login}</span>
106
                    <span class="admin-identity__names">
107
                      <strong :if={account.github_name}>{account.github_name}</strong>
108
                      <span>@{account.github_login}</span>
114 109
                    </span>
115 110
                  </div>
116
117
                  <div class="admin-state">
118
                    <.badge variant={status_variant(call.status)}>
119
                      {String.upcase(call.status)}
120
                    </.badge>
121
                    <.badge :if={call.termination_reason} variant={:dim}>
122
                      {call.termination_reason}
123
                    </.badge>
124
                    <.badge :if={call.failure_code} variant={:danger}>{call.failure_code}</.badge>
125
                  </div>
126
127
                  <dl class="admin-meta">
128
                    <div>
129
                      <dt>Started</dt>
130
                      <dd>{format_timestamp(call.started_at)}</dd>
131
                    </div>
132
                    <div>
133
                      <dt>Call length</dt>
134
                      <dd>{format_call_length(call)}</dd>
135
                    </div>
136
                    <div>
137
                      <dt>Model</dt>
138
                      <dd>{call.model_id}</dd>
139
                    </div>
140
                    <div>
141
                      <dt>Tokens</dt>
142
                      <dd>{format_count(call.total_tokens)}</dd>
143
                    </div>
144
                    <div>
145
                      <dt>Transcript</dt>
146
                      <dd>{format_count(call.transcript_item_count)} items</dd>
147
                    </div>
148
                    <div :if={call.recording}>
149
                      <dt>Audio</dt>
150
                      <dd>
151
                        {format_bytes(call.recording.byte_size)} · {Call.completeness(call)}
152
                      </dd>
153
                    </div>
154
                  </dl>
155
156
                  <p :if={is_nil(call.recording)} class="admin-absence">
157
                    {Call.absence_reason(call)}
158
                  </p>
159
                </div>
160
              </.card>
161
            </li>
162
          </ol>
163
164
          <nav :if={@totals.calls > page_size()} class="admin-pager" aria-label="Call pages">
165
            <.button
166
              id="admin-previous"
167
              variant={:secondary}
168
              size={:sm}
169
              disabled={@offset == 0}
170
              phx-click="previous_page"
171
            >
172
              <.icon name="arrow-left" /> NEWER
173
            </.button>
174
            <span>{page_label(@offset, @calls, @totals.calls)}</span>
175
            <.button
176
              id="admin-next"
177
              variant={:secondary}
178
              size={:sm}
179
              disabled={@offset + page_size() >= @totals.calls}
180
              phx-click="next_page"
181
            >
182
              OLDER
183
            </.button>
184
          </nav>
185
        </section>
186
      </main>
111
                </td>
112
                <td>{date(account.joined_at)}</td>
113
                <td>{date(account.last_authenticated_at)}</td>
114
                <td class="admin-table__number">{account.message_count}</td>
115
                <td class="admin-table__number">{account.issue_count}</td>
116
                <td>
117
                  <.badge variant={status_variant(account.status)}>
118
                    {String.upcase(account.status)}
119
                  </.badge>
120
                </td>
121
              </tr>
122
            </tbody>
123
          </table>
124
        </div>
125
126
        <footer :if={@total > page_size()} class="admin-paging">
127
          <.button
128
            id="previous-page"
129
            variant={:secondary}
130
            size={:sm}
131
            phx-click="previous_page"
132
            disabled={@offset == 0}
133
          >
134
            Previous
135
          </.button>
136
          <span class="admin-paging__position">
137
            {@offset + 1}–{min(@offset + page_size(), @total)} of {@total}
138
          </span>
139
          <.button
140
            id="next-page"
141
            variant={:secondary}
142
            size={:sm}
143
            phx-click="next_page"
144
            disabled={@offset + page_size() >= @total}
145
          >
146
            Next
147
          </.button>
148
        </footer>
149
      </section>
187 150
    </Layouts.app>
188 151
    """
189 152
  end
190 153
191
  # The panel is paged rather than streamed: an operator scanning recent calls
192
  # wants a bounded page and the recording projection remains metadata-only.
193
  defp page_size, do: 25
194
195
  defp page_label(offset, calls, total) do
196
    "#{offset + 1}–#{offset + length(calls)} of #{total}"
197
  end
198
199
  defp sealed_label(%{sealed?: true}), do: "encrypted"
200
  defp sealed_label(%{sealed?: false}), do: "unencrypted"
201
202
  defp status_variant("ended"), do: :dim
203
  defp status_variant("failed"), do: :danger
204
  defp status_variant(_active), do: :success
154
  # `@page_size` inside ~H would be an assign lookup, not this attribute.
155
  defp page_size, do: @page_size
205 156
206
  # The browser's own duration claim when it uploaded one, otherwise the durable
207
  # session clock. Labeled the same either way, because neither is a measurement
208
  # of the audio itself.
209
  defp format_call_length(%Call{recording: %{client_duration_ms: ms}}) when is_integer(ms),
210
    do: format_duration_ms(ms)
157
  # A date, not a timestamp. An operator scanning a list wants to know roughly
158
  # when, and to-the-second precision in a column invites arithmetic nobody
159
  # asked for.
160
  defp date(nil), do: "—"
161
  defp date(%DateTime{} = at), do: at |> DateTime.to_date() |> Date.to_iso8601()
211 162
212
  defp format_call_length(%Call{started_at: started, ended_at: ended})
213
       when not is_nil(started) and not is_nil(ended),
214
       do: started |> DateTime.diff(ended, :millisecond) |> abs() |> format_duration_ms()
163
  defp date(%NaiveDateTime{} = at),
164
    do: at |> NaiveDateTime.to_date() |> Date.to_iso8601()
215 165
216
  defp format_call_length(%Call{}), do: "—"
217
218
  defp format_duration_ms(ms) when is_integer(ms) and ms >= 0 do
219
    total_seconds = div(ms, 1_000)
220
    minutes = div(total_seconds, 60)
221
    seconds = rem(total_seconds, 60)
222
    "#{minutes}:#{String.pad_leading(Integer.to_string(seconds), 2, "0")}"
223
  end
224
225
  defp format_timestamp(nil), do: "—"
226
227
  defp format_timestamp(%DateTime{} = at) do
228
    at
229
    |> DateTime.truncate(:second)
230
    |> Calendar.strftime("%Y-%m-%d %H:%M:%S UTC")
231
  end
232
233
  defp format_bytes(bytes) when is_integer(bytes) and bytes < 1_024, do: "#{bytes} B"
234
235
  defp format_bytes(bytes) when is_integer(bytes) and bytes < 1_048_576,
236
    do: "#{Float.round(bytes / 1_024, 1)} KB"
237
238
  defp format_bytes(bytes) when is_integer(bytes),
239
    do: "#{Float.round(bytes / 1_048_576, 1)} MB"
240
241
  defp format_bytes(_bytes), do: "0 B"
242
243
  defp format_count(count) when is_integer(count) do
244
    count
245
    |> Integer.to_string()
246
    |> String.reverse()
247
    |> String.replace(~r/(\d{3})(?=\d)/, "\\1,")
248
    |> String.reverse()
249
  end
166
  defp status_variant("active"), do: :success
167
  defp status_variant("banned"), do: :danger
168
  defp status_variant(_status), do: :default
250 169
end
test/openagents_web/live/admin_live_test.exs modified +45 -119

@@ -3,17 +3,16 @@ defmodule OpenAgentsWeb.AdminLiveTest do

3 3
  `/admin` is the only surface that reads across accounts for one person, so the
4 4
  gate matters more than the layout: who reaches it, who is told nothing, and
5 5
  what the page is allowed to show once it renders.
6
7
  It lists accounts and activity counts. It does not list content, and the
8
  assertions below hold that line: knowing someone sent forty messages is an
9
  operational fact, reading them is a different decision entirely.
6 10
  """
7 11
8 12
  use OpenAgentsWeb.ConnCase, async: false
9 13
  import Phoenix.LiveViewTest
10 14
11 15
  alias OpenAgents.Conversations
12
  alias OpenAgents.Voice
13
  alias OpenAgents.Voice.Config
14
  alias OpenAgents.Voice.Recordings
15
16
  @webm "audio/webm;codecs=opus"
17 16
18 17
  describe "access" do
19 18
    test "the operator reaches the panel", %{conn: conn} do

@@ -21,7 +20,7 @@ defmodule OpenAgentsWeb.AdminLiveTest do

21 20
22 21
      {:ok, _view, html} = live(conn, ~p"/admin")
23 22
24
      assert html =~ "Voice calls"
23
      assert html =~ "Accounts"
25 24
    end
26 25
27 26
    test "an ordinary authenticated account is redirected and told nothing", %{conn: conn} do

@@ -81,144 +80,71 @@ defmodule OpenAgentsWeb.AdminLiveTest do

81 80
  end
82 81
83 82
  describe "the panel" do
84
    test "lists recording metadata without exposing a playback route", %{conn: conn} do
85
      caller = github_user("admin-recorded-caller")
86
      session = recorded_call(caller)
83
    test "lists every account with when it joined and what it has done", %{conn: conn} do
84
      subject = github_user("admin-listed-account")
85
      {:ok, conversation} = Conversations.ensure_conversation(subject)
87 86
88
      conn = log_in_admin_user(conn, "admin-listener")
87
      {:ok, _records} =
88
        Conversations.create_turn(conversation, "a message the operator must not read")
89
90
      conn = log_in_admin_user(conn, "admin-lister")
89 91
      {:ok, view, html} = live(conn, ~p"/admin")
90 92
91
      assert html =~ "@#{caller.github_login}"
92
      assert has_element?(view, "#admin-call-#{session.id}")
93
      refute has_element?(view, "audio")
94
      refute html =~ "/admin/recordings/"
95
      assert html =~ "Complete upload"
93
      assert html =~ "@#{subject.github_login}"
94
      assert has_element?(view, "#admin-account-#{subject.id}")
95
      assert has_element?(view, "#admin-accounts")
96 96
    end
97 97
98
    test "lists calls with no audio and says why, instead of hiding them", %{conn: conn} do
99
      caller = github_user("admin-silent-caller")
100
      {:ok, conversation} = Conversations.ensure_conversation(caller)
101
      {:ok, session} = Voice.admit_session(conversation, enabled_config())
102
      {:ok, _ended} = Voice.end_session(session, session.generation, "user_ended")
98
    test "counts activity without exposing any of its content", %{conn: conn} do
99
      subject = github_user("admin-content-account")
100
      {:ok, conversation} = Conversations.ensure_conversation(subject)
101
      {:ok, _records} = Conversations.create_turn(conversation, "the private text of a message")
103 102
104
      conn = log_in_admin_user(conn, "admin-silent-listener")
105
      {:ok, view, html} = live(conn, ~p"/admin")
103
      conn = log_in_admin_user(conn, "admin-content-reader")
104
      {:ok, _view, html} = live(conn, ~p"/admin")
106 105
107
      assert has_element?(view, "#admin-call-#{session.id}")
108
      assert html =~ "No audio uploaded"
109
      refute has_element?(view, "#admin-audio-#{session.id}")
106
      refute html =~ "the private text of a message"
110 107
    end
111 108
112
    test "renders transcript counts but never transcript content", %{conn: conn} do
113
      caller = github_user("admin-transcript-caller")
114
      session = recorded_call(caller)
115
116
      {:ok, session} = Voice.attach_provider(session, session.generation, "rtc_admin_transcript")
117
118
      {:ok, session, _event, :created} =
119
        Voice.record_provider_event(
120
          session,
121
          session.generation,
122
          %OpenAgents.Voice.ProviderEvent{
123
            kind: :session_ready,
124
            provider_event_id: "evt-admin-ready",
125
            payload: %{}
126
          }
127
        )
128
129
      {:ok, _session, _event, :created} =
130
        Voice.record_provider_event(
131
          session,
132
          session.generation,
133
          %OpenAgents.Voice.ProviderEvent{
134
            kind: :user_transcript_final,
135
            provider_event_id: "evt-admin-user",
136
            payload: %{
137
              "item_id" => "item-admin-user",
138
              "response_id" => nil,
139
              "content" => "my private banking password is hunter2"
140
            }
141
          }
142
        )
143
144
      conn = log_in_admin_user(conn, "admin-transcript-listener")
145
      {:ok, _view, html} = live(conn, ~p"/admin")
109
    test "carries no recording surface at all", %{conn: conn} do
110
      conn = log_in_admin_user(conn, "admin-no-recording")
111
      {:ok, view, html} = live(conn, ~p"/admin")
146 112
147
      assert html =~ "1 items"
148
      refute html =~ "hunter2"
113
      # The product does not capture call audio, and an operator panel whose
114
      # only view is of a capability that does not run describes the system
115
      # inaccurately to the person who most needs an accurate picture.
116
      refute has_element?(view, "audio")
117
      refute html =~ "/admin/recordings/"
118
      refute html =~ "Voice calls"
119
      refute html =~ "RECORDING"
149 120
    end
150 121
151
    test "renders no composed instructions, tool catalog, or provider call identity",
122
    test "an account with no activity is listed with zeroes rather than dropped",
152 123
         %{conn: conn} do
153
      caller = github_user("admin-secrets-caller")
154
      session = recorded_call(caller)
155
      {:ok, attached} = Voice.attach_provider(session, session.generation, "rtc_admin_secret")
124
      quiet = github_user("admin-quiet-account")
156 125
157
      conn = log_in_admin_user(conn, "admin-secrets-listener")
158
      {:ok, _view, html} = live(conn, ~p"/admin")
126
      conn = log_in_admin_user(conn, "admin-quiet-reader")
127
      {:ok, view, _html} = live(conn, ~p"/admin")
159 128
160
      refute html =~ attached.provider_session_id
161
      refute html =~ attached.instruction_digest
162
      refute html =~ "sarah.realtime_tool_catalog"
129
      assert has_element?(view, "#admin-account-#{quiet.id}")
163 130
    end
164 131
165
    test "states the retention window, because listenable forever is a different promise",
132
    test "renders no composed instructions, tool catalog, or provider call identity",
166 133
         %{conn: conn} do
167
      conn = log_in_admin_user(conn, "admin-retention-listener")
134
      conn = log_in_admin_user(conn, "admin-no-internals")
168 135
      {:ok, _view, html} = live(conn, ~p"/admin")
169 136
170
      assert html =~ "#{Recordings.config().retention_days} days after a call ends"
171
    end
172
173
    test "the browser policy admits same-origin audio without loosening anything", %{conn: conn} do
174
      conn = log_in_admin_user(conn, "admin-csp-operator")
175
      response = get(conn, ~p"/admin")
176
177
      [policy] = get_resp_header(response, "content-security-policy")
178
179
      # No `media-src` directive, so audio falls back to `default-src 'self'`.
180
      # Asserted rather than assumed: a later directive added for another reason
181
      # would silently break playback.
182
      assert policy =~ "default-src 'self'"
183
      refute policy =~ "media-src"
184
    end
185
186
    test "shows an empty state before anyone has called", %{conn: conn} do
187
      conn = log_in_admin_user(conn, "admin-empty-listener")
188
      {:ok, view, _html} = live(conn, ~p"/admin")
189
190
      assert has_element?(view, "#admin-empty")
137
      refute html =~ "system_prompt"
138
      refute html =~ "tool_catalog"
139
      refute html =~ "gpt-realtime"
191 140
    end
192 141
193
    test "renders the shared command bar with only the way back", %{conn: conn} do
194
      conn = log_in_admin_user(conn, "admin-chrome-operator")
142
    test "the shell supplies the chrome; the panel builds none of its own", %{conn: conn} do
143
      conn = log_in_admin_user(conn, "admin-chrome")
195 144
      {:ok, view, _html} = live(conn, ~p"/admin")
196 145
197 146
      refute has_element?(view, "header.command-bar")
198
      assert has_element?(view, ~s(#sidebar a.sidebar-row__hit[href="/chat"]))
199
      assert has_element?(view, "#account-bar-trigger")
200
      # Nothing navigates onward from here, and nothing in the product links in.
201
      refute has_element?(view, "#open-leaderboard")
147
      assert has_element?(view, "#sidebar")
202 148
    end
203 149
  end
204
205
  defp recorded_call(user) do
206
    {:ok, conversation} = Conversations.ensure_conversation(user)
207
    {:ok, session} = Voice.admit_session(conversation, enabled_config())
208
    {:ok, _chunk} = Recordings.append_chunk(session, session.generation, 1, "opus-bytes", @webm)
209
    {:ok, _recording} = Recordings.finalize(session, session.generation, "complete", 3_000)
210
    session
211
  end
212
213
  defp enabled_config do
214
    Config.build!(
215
      enabled: true,
216
      architecture: :openai_realtime,
217
      provider: "openai",
218
      model: "gpt-realtime-2.1",
219
      voice: "marin",
220
      reasoning_effort: "low",
221
      maximum_session_seconds: 3_000
222
    )
223
  end
224 150
end

This page updates live while a promote is in flight · changelog