Give every thread a page, read-only and live

e874b6a1d570 · AtlantisPleb · · parent c26c1888e2cd

Give every thread a page, read-only and live

The first slice of the web thread viewer (#201). /threads lists the
account's threads as a shell projection — status, objective as the row
title, event count, opened and last-event times, terminal spend — and
never reads a transcript for a row. /threads/:id renders the transcript
with the event vocabulary typed: turn.user with its steered marker,
turn.reasoning as a collapsible block, tool.ran with the tool name and
bounded arguments and result, turn.assistant as sanitized Markdown with
a usage line. An unknown type, or a typed payload missing the field it
renders around, degrades to a neutral raw row rather than crashing the
transcript.

Owner-only follows the API's own scoping: the page resolves through
Threads.get_for_user/2 and renders nil as the plain 404 an unknown id
gets, so the browser confirms no more existence than the API. THREAD-001
and its reach test are amended for the new resolver caller and the new
subscribe/1 export.

Live updates follow the projection protocol the issue names: the detail
page subscribes to the thread's topic before reading the snapshot, and
Threads.record_event/3 broadcasts {:thread_event, event} after its
transaction commits, so a subscriber never sees an event that rolled
back. Buffered or replayed broadcasts dedup on the monotonic event id.
The list page is a mount-time snapshot by design.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <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.

pushed
by user · WAL seq 300 · 2026-08-24T20:07:50.263899Z

Changed files

  • modified INVARIANTS.md
  • modified lib/openagents/threads.ex
  • added lib/openagents_web/live/thread_index_live.ex
  • added lib/openagents_web/live/thread_show_live.ex
  • modified lib/openagents_web/router.ex
  • added test/openagents/threads/event_broadcast_test.exs
  • modified test/openagents/threads/grant_token_reach_test.exs
  • added test/openagents_web/live/thread_index_live_test.exs
  • added test/openagents_web/live/thread_show_live_test.exs
  • added test/openagents_web/live/thread_show_updates_test.exs

Diff

10 files changed, +843 -10

INVARIANTS.md modified +10 -5

@@ -2009,9 +2009,13 @@ conversation, and a thread is not one.

2009 2009
- **Authority reaches only the account that opened the thread.**
2010 2010
  `OpenAgents.Threads.get_for_user/2` joins through the owner visitor, so
2011 2011
  another account's thread id resolves to `nil` and the route refuses it with
2012
  the same `not_found` an absent id gets. No route returns a grant token for a
2013
  thread the caller did not open, and the token is returned exactly once, at
2014
  the mint.
2012
  the same `not_found` an absent id gets. The web thread viewer
2013
  (`OpenAgentsWeb.ThreadShowLive`) resolves through the same lookup and
2014
  renders `nil` as the same plain 404 an unknown id gets, so the browser
2015
  confirms no more existence than the API, and it reads the transcript and the
2016
  grant meter without ever holding a plaintext token. No route returns a grant
2017
  token for a thread the caller did not open, and the token is returned
2018
  exactly once, at the mint.
2015 2019
2016 2020
  Amended 2026-08-23 (issue #174): that sentence quantifies over routes, so it
2017 2021
  is enumerated rather than sampled. A plaintext grant token comes into

@@ -2037,8 +2041,9 @@ Evidence: `OpenAgents.Threads`, `OpenAgents.Threads.Thread`,

2037 2041
`priv/repo/migrations/20260823221416_allow_thread_scoped_inference_grants.exs`,
2038 2042
`test/openagents/threads/grant_fence_test.exs`,
2039 2043
`test/openagents/threads/grant_token_reach_test.exs`,
2040
`test/openagents/threads_test.exs`, and
2041
`test/openagents_web/controllers/thread_controller_test.exs`.
2044
`test/openagents/threads_test.exs`,
2045
`test/openagents_web/controllers/thread_controller_test.exs`, and
2046
`test/openagents_web/live/thread_show_live_test.exs`.
2042 2047
2043 2048
## Tenant deployment control plane
2044 2049
lib/openagents/threads.ex modified +30 -2

@@ -232,6 +232,10 @@ defmodule OpenAgents.Threads do

232 232
233 233
  Refused on a terminal thread: a transcript that keeps growing after the
234 234
  report was written is not the transcript the report describes.
235
236
  A committed append is broadcast as `{:thread_event, event}` on the thread's
237
  topic (`subscribe/1`), after the transaction, so a subscriber never sees an
238
  event that rolled back.
235 239
  """
236 240
  @spec record_event(Thread.t(), String.t(), map()) ::
237 241
          {:ok, Thread.t()} | {:error, :thread_terminal | Ecto.Changeset.t()}

@@ -242,10 +246,10 @@ defmodule OpenAgents.Threads do

242 246
    Repo.transaction(fn ->
243 247
      case locked(thread.id) do
244 248
        %Thread{status: "open"} = current ->
245
          with {:ok, _event} <- insert_event(current, event_type, payload, now),
249
          with {:ok, event} <- insert_event(current, event_type, payload, now),
246 250
               {:ok, updated} <-
247 251
                 current |> Thread.event_count_changeset(current.event_count + 1) |> Repo.update() do
248
            updated
252
            {updated, event}
249 253
          else
250 254
            {:error, reason} -> Repo.rollback(reason)
251 255
          end

@@ -254,8 +258,32 @@ defmodule OpenAgents.Threads do

254 258
          Repo.rollback(:thread_terminal)
255 259
      end
256 260
    end)
261
    |> case do
262
      {:ok, {updated, event}} ->
263
        Phoenix.PubSub.broadcast(OpenAgents.PubSub, topic(updated.id), {:thread_event, event})
264
        {:ok, updated}
265
266
      {:error, reason} ->
267
        {:error, reason}
268
    end
257 269
  end
258 270
271
  @doc """
272
  Subscribe to a thread's transcript appends.
273
274
  Delivers `{:thread_event, %OpenAgents.Threads.Event{}}` for each committed
275
  append. Subscribe before reading the snapshot and dedup by the event id,
276
  which is monotonic per thread.
277
  """
278
  @spec subscribe(Thread.t() | String.t()) :: :ok | {:error, term()}
279
  def subscribe(%Thread{id: thread_id}), do: subscribe(thread_id)
280
281
  def subscribe(thread_id) when is_binary(thread_id) do
282
    Phoenix.PubSub.subscribe(OpenAgents.PubSub, topic(thread_id))
283
  end
284
285
  defp topic(thread_id), do: "thread:" <> thread_id
286
259 287
  @doc """
260 288
  Mint model authority for a thread.
261 289
lib/openagents_web/live/thread_index_live.ex added +126

@@ -0,0 +1,126 @@

1
defmodule OpenAgentsWeb.ThreadIndexLive do
2
  @moduledoc """
3
  The account's threads as a shell projection.
4
5
  Each row reads only the denormalized thread record — status, objective,
6
  counts, timestamps, terminal usage — never the transcript (issue #201's
7
  shell/detail split). The row title is the thread's objective: it is what the
8
  reader asked for, it lives on the shell row, and deriving the first
9
  `turn.user` event would mean reading transcripts for a listing. The list is a
10
  snapshot taken at mount; live updates belong to the detail page, which
11
  subscribes to its one thread's topic.
12
  """
13
14
  use OpenAgentsWeb, :live_view
15
16
  alias OpenAgents.Threads
17
18
  @title_characters 100
19
20
  @impl true
21
  def mount(_params, _session, socket) do
22
    user = socket.assigns.current_user
23
    _reaped = Threads.reap_expired(user)
24
25
    threads = Threads.list_for_user(user)
26
27
    {:ok,
28
     socket
29
     |> assign(:page_title, "Threads · OpenAgents")
30
     |> assign(:threads_empty?, threads == [])
31
     |> stream(:threads, threads)}
32
  end
33
34
  @impl true
35
  def render(assigns) do
36
    ~H"""
37
    <Layouts.app
38
      flash={@flash}
39
      sidebar_sections={assigns[:sidebar_sections]}
40
      current_scope={@current_scope}
41
    >
42
      <main id="thread-index" class="mx-auto w-full max-w-5xl space-y-6 px-4 py-10">
43
        <.header>
44
          Threads
45
          <:subtitle>Every thread this account has opened, newest first.</:subtitle>
46
        </.header>
47
48
        <.empty :if={@threads_empty?} id="threads-empty" title="No threads yet">
49
          Open one with
50
          <.kbd>openagents coder</.kbd>
51
          or <.kbd>POST /api/v3/threads</.kbd>.
52
        </.empty>
53
54
        <.table :if={!@threads_empty?} id="threads-table" rows={@streams.threads}>
55
          <:col :let={{_id, thread}} label="Objective">
56
            <.link
57
              navigate={~p"/threads/#{thread.id}"}
58
              id={"thread-link-#{thread.id}"}
59
              class="font-medium hover:underline"
60
            >
61
              {title(thread.objective)}
62
            </.link>
63
          </:col>
64
          <:col :let={{_id, thread}} label="Status">
65
            <.badge variant={status_variant(thread.status)}>{thread.status}</.badge>
66
          </:col>
67
          <:col :let={{_id, thread}} label="Events">
68
            <span class="tabular-nums">{thread.event_count}</span>
69
          </:col>
70
          <:col :let={{_id, thread}} label="Model">
71
            <span class="font-mono text-xs text-muted-foreground">{thread.model}</span>
72
          </:col>
73
          <:col :let={{_id, thread}} label="Opened">
74
            <.time_ago at={thread.started_at} class="text-muted-foreground" />
75
          </:col>
76
          <:col :let={{_id, thread}} label="Last event">
77
            <.time_ago at={thread.updated_at} class="text-muted-foreground" />
78
          </:col>
79
          <:col :let={{_id, thread}} label="Spent">
80
            <span class="tabular-nums text-muted-foreground">{spent(thread.usage)}</span>
81
          </:col>
82
        </.table>
83
      </main>
84
    </Layouts.app>
85
    """
86
  end
87
88
  defp title(objective) do
89
    if String.length(objective) > @title_characters do
90
      String.slice(objective, 0, @title_characters) <> "…"
91
    else
92
      objective
93
    end
94
  end
95
96
  defp status_variant("open"), do: :info
97
  defp status_variant("succeeded"), do: :success
98
  defp status_variant("failed"), do: :danger
99
  defp status_variant("cancelled"), do: :dim
100
  defp status_variant(_status), do: :default
101
102
  # The terminal usage map is client-reported and its shape is not pinned, so
103
  # this reads the keys the grant meter uses and shows nothing otherwise.
104
  defp spent(usage) when is_map(usage) do
105
    case integer(usage, "estimated_cost_microusd") do
106
      nil ->
107
        case integer(usage, "total_tokens") do
108
          nil -> "—"
109
          tokens -> "#{tokens} tok"
110
        end
111
112
      microusd ->
113
        "$#{:erlang.float_to_binary(microusd / 1_000_000, decimals: 2)}"
114
    end
115
  end
116
117
  defp spent(_usage), do: "—"
118
119
  defp integer(map, key) do
120
    case Map.get(map, key) do
121
      value when is_integer(value) -> value
122
      value when is_float(value) -> trunc(value)
123
      _absent -> nil
124
    end
125
  end
126
end
lib/openagents_web/live/thread_show_live.ex added +404

@@ -0,0 +1,404 @@

1
defmodule OpenAgentsWeb.ThreadShowLive do
2
  @moduledoc """
3
  One thread's transcript, read-only, live.
4
5
  Owner-only: an unknown id and another account's id both raise the plain 404
6
  (`OpenAgentsWeb.PublicNotFoundError`), matching how the API's
7
  `Threads.get_for_user/2` scopes reads — existence is never confirmed to a
8
  non-owner.
9
10
  The snapshot-to-live order follows the projection protocol the issue names:
11
  subscribe to the thread's topic first, then read the snapshot, then let
12
  buffered broadcasts drain through `handle_info/2` with a monotonic
13
  `last_event_id` dedup, so an event is never dropped and never doubled.
14
15
  The event vocabulary renders typed — `turn.user`, `turn.reasoning`,
16
  `tool.ran`, `turn.assistant` — and every payload field is read defensively:
17
  the payloads are client-written and their shape is not pinned by the server,
18
  so a missing or oddly typed field degrades to the neutral raw row rather
19
  than crashing the view. Unknown event types take the raw row too.
20
  """
21
22
  use OpenAgentsWeb, :live_view
23
24
  alias OpenAgents.Markdown
25
  alias OpenAgents.Threads
26
27
  # Transcript pages are capped at 50 by the context; forty pages bounds the
28
  # view at 2,000 events, which is past any measured session.
29
  @maximum_pages 40
30
  @bounded_json_characters 2_000
31
32
  @impl true
33
  def mount(%{"id" => thread_id}, _session, socket) do
34
    user = socket.assigns.current_user
35
    _reaped = Threads.reap_expired(user)
36
37
    case Threads.get_for_user(user, thread_id) do
38
      nil ->
39
        raise OpenAgentsWeb.PublicNotFoundError, message: "thread not found"
40
41
      thread ->
42
        # Attach the live subscriber before reading the snapshot: an append
43
        # that lands between the two arrives as a buffered message and is
44
        # deduped below by id, so the gap cannot lose an event.
45
        if connected?(socket), do: Threads.subscribe(thread)
46
47
        events = transcript(thread)
48
49
        {:ok,
50
         socket
51
         |> assign(:page_title, "Thread · OpenAgents")
52
         |> assign(:thread, thread)
53
         |> assign(:grant, Threads.latest_grant(thread))
54
         |> assign(:last_event_id, last_id(events))
55
         |> assign(:events_empty?, events == [])
56
         |> stream(:events, events)}
57
    end
58
  end
59
60
  @impl true
61
  def handle_info({:thread_event, event}, socket) do
62
    if event.id <= socket.assigns.last_event_id do
63
      {:noreply, socket}
64
    else
65
      {:noreply,
66
       socket
67
       |> assign(:last_event_id, event.id)
68
       |> assign(:events_empty?, false)
69
       |> update(:thread, fn thread -> %{thread | event_count: thread.event_count + 1} end)
70
       |> stream_insert(:events, event)}
71
    end
72
  end
73
74
  @impl true
75
  def render(assigns) do
76
    ~H"""
77
    <Layouts.app
78
      flash={@flash}
79
      sidebar_sections={assigns[:sidebar_sections]}
80
      current_scope={@current_scope}
81
    >
82
      <main id="thread-show" class="mx-auto w-full max-w-4xl space-y-6 px-4 py-10">
83
        <header class="space-y-3">
84
          <div class="flex flex-wrap items-center gap-3">
85
            <.badge variant={status_variant(@thread.status)}>{@thread.status}</.badge>
86
            <span class="font-mono text-xs text-muted-foreground">{@thread.id}</span>
87
          </div>
88
          <h1 class="text-2xl font-semibold tracking-tight">{@thread.objective}</h1>
89
          <dl
90
            id="thread-facts"
91
            class="flex flex-wrap gap-x-6 gap-y-1 text-sm text-muted-foreground"
92
          >
93
            <div class="flex gap-1.5">
94
              <dt>Model</dt>
95
              <dd class="font-mono text-xs leading-5">{@thread.model}</dd>
96
            </div>
97
            <div class="flex gap-1.5">
98
              <dt>Reasoning</dt>
99
              <dd>{@thread.reasoning_effort}</dd>
100
            </div>
101
            <div class="flex gap-1.5">
102
              <dt>Permissions</dt>
103
              <dd>{@thread.permission_profile}</dd>
104
            </div>
105
            <div class="flex gap-1.5">
106
              <dt>Events</dt>
107
              <dd id="thread-event-count" class="tabular-nums">{@thread.event_count}</dd>
108
            </div>
109
            <div class="flex gap-1.5">
110
              <dt>Opened</dt>
111
              <dd><.time_ago at={@thread.started_at} /></dd>
112
            </div>
113
            <div :if={@thread.completed_at} class="flex gap-1.5">
114
              <dt>Completed</dt>
115
              <dd><.time_ago at={@thread.completed_at} /></dd>
116
            </div>
117
          </dl>
118
        </header>
119
120
        <.card :if={@grant} id="thread-budget" class="text-sm">
121
          <div class="flex flex-wrap gap-x-8 gap-y-2">
122
            <div>
123
              <div class="text-xs text-muted-foreground">Grant</div>
124
              <div>{@grant.status}</div>
125
            </div>
126
            <div>
127
              <div class="text-xs text-muted-foreground">Calls</div>
128
              <div class="tabular-nums">{@grant.call_count} / {@grant.max_calls}</div>
129
            </div>
130
            <div>
131
              <div class="text-xs text-muted-foreground">Tokens</div>
132
              <div class="tabular-nums">
133
                {grant_spent(@grant, "total_tokens")} / {@grant.max_total_tokens}
134
              </div>
135
            </div>
136
            <div>
137
              <div class="text-xs text-muted-foreground">Cost</div>
138
              <div class="tabular-nums">
139
                {dollars(grant_spent(@grant, "estimated_cost_microusd"))} / {dollars(
140
                  @grant.max_cost_microusd
141
                )}
142
              </div>
143
            </div>
144
            <div :if={@grant.expires_at}>
145
              <div class="text-xs text-muted-foreground">Expires</div>
146
              <div><.time_ago at={@grant.expires_at} /></div>
147
            </div>
148
          </div>
149
        </.card>
150
151
        <.card :if={@thread.report} id="thread-report" class="text-sm">
152
          <div class="mb-1 text-xs text-muted-foreground">Report</div>
153
          <div class="whitespace-pre-wrap">{@thread.report}</div>
154
        </.card>
155
156
        <section aria-label="Transcript" class="space-y-3">
157
          <.empty :if={@events_empty?} id="thread-transcript-empty" title="No events yet">
158
            The transcript fills as the thread works.
159
          </.empty>
160
161
          <div id="thread-events" phx-update="stream" class="space-y-3">
162
            <div :for={{dom_id, event} <- @streams.events} id={dom_id} data-kind={event.event_type}>
163
              <.event_row event={event} />
164
            </div>
165
          </div>
166
        </section>
167
      </main>
168
    </Layouts.app>
169
    """
170
  end
171
172
  # ── event rendering ──────────────────────────────────────────────────────
173
174
  # One transcript entry, dispatched on its type. Each typed clause reads its
175
  # payload defensively and falls back to the neutral raw row when the field
176
  # it renders around is missing, so a malformed payload degrades instead of
177
  # crashing the transcript.
178
  defp event_row(%{event: %{event_type: "turn.user"} = event} = assigns) do
179
    case text(event.payload) do
180
      nil ->
181
        raw_row(assigns)
182
183
      text ->
184
        assigns = assign(assigns, text: text, steered: steered?(event.payload))
185
186
        ~H"""
187
        <.card class="text-sm">
188
          <div class="mb-1 flex items-center gap-2 text-xs text-muted-foreground">
189
            <span class="font-medium text-foreground">You</span>
190
            <.badge :if={@steered} variant={:warning}>steered</.badge>
191
            <.time_ago at={@event.emitted_at} />
192
          </div>
193
          <div class="whitespace-pre-wrap">{@text}</div>
194
        </.card>
195
        """
196
    end
197
  end
198
199
  defp event_row(%{event: %{event_type: "turn.reasoning"} = event} = assigns) do
200
    case text(event.payload) do
201
      nil ->
202
        raw_row(assigns)
203
204
      text ->
205
        assigns = assign(assigns, text: text)
206
207
        ~H"""
208
        <details class="rounded-md border border-border">
209
          <summary class="cursor-pointer select-none px-3 py-2 text-xs text-muted-foreground">
210
            Reasoning · {String.length(@text)} chars · <.time_ago at={@event.emitted_at} />
211
          </summary>
212
          <div class="whitespace-pre-wrap border-t border-border px-3 py-2 text-sm text-muted-foreground">
213
            {@text}
214
          </div>
215
        </details>
216
        """
217
    end
218
  end
219
220
  defp event_row(%{event: %{event_type: "tool.ran"} = event} = assigns) do
221
    case string(event.payload, ["tool", "name"]) do
222
      nil ->
223
        raw_row(assigns)
224
225
      tool ->
226
        assigns =
227
          assign(assigns,
228
            tool: tool,
229
            tool_status: string(event.payload, ["status"]),
230
            arguments: bounded_json(Map.get(event.payload, "arguments")),
231
            result: bounded_json(Map.get(event.payload, "result"))
232
          )
233
234
        ~H"""
235
        <details class="rounded-md border border-border">
236
          <summary class="flex cursor-pointer select-none items-center gap-2 px-3 py-2 text-xs">
237
            <.kbd>{@tool}</.kbd>
238
            <.badge :if={@tool_status} variant={tool_status_variant(@tool_status)}>
239
              {@tool_status}
240
            </.badge>
241
            <span class="text-muted-foreground"><.time_ago at={@event.emitted_at} /></span>
242
          </summary>
243
          <div class="space-y-2 border-t border-border px-3 py-2 text-xs">
244
            <div :if={@arguments}>
245
              <div class="mb-1 text-muted-foreground">Arguments</div>
246
              <pre class="overflow-x-auto rounded-md bg-muted p-2 font-mono"><code phx-no-format>{@arguments}</code></pre>
247
            </div>
248
            <div :if={@result}>
249
              <div class="mb-1 text-muted-foreground">Result</div>
250
              <pre class="overflow-x-auto rounded-md bg-muted p-2 font-mono"><code phx-no-format>{@result}</code></pre>
251
            </div>
252
          </div>
253
        </details>
254
        """
255
    end
256
  end
257
258
  defp event_row(%{event: %{event_type: "turn.assistant"} = event} = assigns) do
259
    case text(event.payload) do
260
      nil ->
261
        raw_row(assigns)
262
263
      text ->
264
        assigns =
265
          assign(assigns,
266
            html: Markdown.to_html(text),
267
            usage: usage_line(event.payload)
268
          )
269
270
        ~H"""
271
        <.card class="text-sm">
272
          <div class="mb-1 flex items-center gap-2 text-xs text-muted-foreground">
273
            <span class="font-medium text-foreground">Assistant</span>
274
            <.time_ago at={@event.emitted_at} />
275
          </div>
276
          <div class="markdown space-y-2">{@html}</div>
277
          <div :if={@usage} class="mt-2 text-xs tabular-nums text-muted-foreground">{@usage}</div>
278
        </.card>
279
        """
280
    end
281
  end
282
283
  defp event_row(assigns), do: raw_row(assigns)
284
285
  # The neutral row: whatever the type, whatever the payload, it renders as
286
  # the recorded fact rather than crashing the transcript around it.
287
  defp raw_row(assigns) do
288
    assigns = assign(assigns, json: bounded_json(assigns.event.payload))
289
290
    ~H"""
291
    <details class="rounded-md border border-border">
292
      <summary class="flex cursor-pointer select-none items-center gap-2 px-3 py-2 text-xs">
293
        <code class="font-mono">{@event.event_type}</code>
294
        <span class="text-muted-foreground"><.time_ago at={@event.emitted_at} /></span>
295
      </summary>
296
      <pre
297
        :if={@json}
298
        class="overflow-x-auto border-t border-border px-3 py-2 font-mono text-xs"
299
      ><code phx-no-format>{@json}</code></pre>
300
    </details>
301
    """
302
  end
303
304
  # ── snapshot ─────────────────────────────────────────────────────────────
305
306
  defp transcript(thread), do: transcript(thread, nil, @maximum_pages, [])
307
308
  defp transcript(_thread, _after_id, 0, pages), do: pages |> Enum.reverse() |> List.flatten()
309
310
  defp transcript(thread, after_id, remaining, pages) do
311
    page = Threads.list_events(thread, after: after_id)
312
313
    case last_id(page) do
314
      0 -> transcript(thread, after_id, 0, pages)
315
      last -> transcript(thread, last, remaining - 1, [page | pages])
316
    end
317
  end
318
319
  defp last_id([]), do: 0
320
  defp last_id(events), do: List.last(events).id
321
322
  # ── payload readers ──────────────────────────────────────────────────────
323
324
  defp text(payload), do: string(payload, ["text"])
325
326
  defp string(payload, keys) when is_map(payload) do
327
    Enum.find_value(keys, fn key ->
328
      case Map.get(payload, key) do
329
        value when is_binary(value) and value != "" -> value
330
        _other -> nil
331
      end
332
    end)
333
  end
334
335
  defp string(_payload, _keys), do: nil
336
337
  defp steered?(payload) when is_map(payload), do: Map.get(payload, "steered") == true
338
  defp steered?(_payload), do: false
339
340
  defp bounded_json(nil), do: nil
341
342
  defp bounded_json(value) do
343
    case Jason.encode(value, pretty: true) do
344
      {:ok, json} when byte_size(json) > @bounded_json_characters ->
345
        String.slice(json, 0, @bounded_json_characters) <> "\n…"
346
347
      {:ok, json} ->
348
        json
349
350
      {:error, _unencodable} ->
351
        inspect(value, limit: 50, printable_limit: @bounded_json_characters)
352
    end
353
  end
354
355
  defp usage_line(payload) when is_map(payload) do
356
    case Map.get(payload, "usage") do
357
      usage when is_map(usage) ->
358
        parts =
359
          [
360
            {"input", integer(usage, "input_tokens") || integer(usage, "prompt_tokens")},
361
            {"output", integer(usage, "output_tokens") || integer(usage, "completion_tokens")},
362
            {"total", integer(usage, "total_tokens")}
363
          ]
364
          |> Enum.filter(fn {_label, value} -> value end)
365
          |> Enum.map(fn {label, value} -> "#{value} #{label}" end)
366
367
        if parts == [], do: nil, else: Enum.join(parts, " · ") <> " tok"
368
369
      _absent ->
370
        nil
371
    end
372
  end
373
374
  defp usage_line(_payload), do: nil
375
376
  defp integer(map, key) do
377
    case Map.get(map, key) do
378
      value when is_integer(value) -> value
379
      value when is_float(value) -> trunc(value)
380
      _absent -> nil
381
    end
382
  end
383
384
  defp grant_spent(grant, key) do
385
    case grant.usage do
386
      usage when is_map(usage) -> integer(usage, key) || 0
387
      _absent -> 0
388
    end
389
  end
390
391
  defp dollars(microusd), do: "$#{:erlang.float_to_binary(microusd / 1_000_000, decimals: 2)}"
392
393
  defp status_variant("open"), do: :info
394
  defp status_variant("succeeded"), do: :success
395
  defp status_variant("failed"), do: :danger
396
  defp status_variant("cancelled"), do: :dim
397
  defp status_variant(_status), do: :default
398
399
  defp tool_status_variant("ok"), do: :success
400
  defp tool_status_variant("success"), do: :success
401
  defp tool_status_variant("error"), do: :danger
402
  defp tool_status_variant("failed"), do: :danger
403
  defp tool_status_variant(_status), do: :default
404
end
lib/openagents_web/router.ex modified +2

@@ -212,6 +212,8 @@ defmodule OpenAgentsWeb.Router do

212 212
      live "/artifact-catalog", ArtifactCatalogLive, :index
213 213
      live "/notifications", NotificationsLive, :index
214 214
      live "/settings/api-tokens", ApiTokensLive, :index
215
      live "/threads", ThreadIndexLive, :index
216
      live "/threads/:id", ThreadShowLive, :show
215 217
      live "/device", DeviceAuthorizationLive, :show
216 218
      live "/repositories", RepositoryIndexLive, :index
217 219
      live "/repositories/new", RepositoryNewLive, :new
test/openagents/threads/event_broadcast_test.exs added +44

@@ -0,0 +1,44 @@

1
defmodule OpenAgents.Threads.EventBroadcastTest do
2
  use OpenAgents.DataCase, async: false
3
4
  import OpenAgentsWeb.ConnCase, only: [github_user: 1]
5
6
  alias OpenAgents.Threads
7
  alias OpenAgents.Threads.Event
8
9
  test "a committed append is broadcast on the thread's topic" do
10
    user = github_user("thread-broadcast")
11
    {:ok, thread} = Threads.open(user, "Broadcast the transcript")
12
13
    :ok = Threads.subscribe(thread)
14
15
    {:ok, _updated} = Threads.record_event(thread, "turn.user", %{"text" => "hello"})
16
17
    assert_receive {:thread_event, %Event{event_type: "turn.user", payload: %{"text" => "hello"}}}
18
  end
19
20
  test "a refused append broadcasts nothing" do
21
    user = github_user("thread-broadcast-terminal")
22
    {:ok, thread} = Threads.open(user, "Terminal threads stay silent")
23
    {:ok, cancelled} = Threads.cancel(thread)
24
25
    :ok = Threads.subscribe(cancelled)
26
27
    assert {:error, :thread_terminal} =
28
             Threads.record_event(cancelled, "turn.user", %{"text" => "late"})
29
30
    refute_receive {:thread_event, _event}
31
  end
32
33
  test "another thread's subscriber hears nothing" do
34
    user = github_user("thread-broadcast-scope")
35
    {:ok, mine} = Threads.open(user, "Mine")
36
    {:ok, other} = Threads.open(user, "Other")
37
38
    :ok = Threads.subscribe(other)
39
40
    {:ok, _updated} = Threads.record_event(mine, "turn.user", %{"text" => "scoped"})
41
42
    refute_receive {:thread_event, _event}
43
  end
44
end
test/openagents/threads/grant_token_reach_test.exs modified +6 -3

@@ -70,7 +70,8 @@ defmodule OpenAgents.Threads.GrantTokenReachTest do

70 70
    {:open_and_mint, 3} => :returns_plaintext_token,
71 71
    {:open_count, 1} => :scoped_by_owner,
72 72
    {:reap_expired, 1} => :scoped_by_owner,
73
    {:record_event, 3} => :thread_struct
73
    {:record_event, 3} => :thread_struct,
74
    {:subscribe, 1} => :thread_struct
74 75
  }
75 76
76 77
  # Every module that reaches a token-returning `OpenAgents.Threads` export.

@@ -79,8 +80,10 @@ defmodule OpenAgents.Threads.GrantTokenReachTest do

79 80
80 81
  # The one function that resolves a thread from an identifier, and every
81 82
  # module that calls it. It takes the acting account, so another account's
82
  # thread id resolves to `nil` (THREAD-001, IDENTITY-002).
83
  @thread_resolver_callers [OpenAgentsWeb.ThreadController]
83
  # thread id resolves to `nil` (THREAD-001, IDENTITY-002). The web thread
84
  # viewer resolves through the same lookup and renders a `nil` as the plain
85
  # 404 an absent id gets.
86
  @thread_resolver_callers [OpenAgentsWeb.ThreadController, OpenAgentsWeb.ThreadShowLive]
84 87
85 88
  test "the modules that mint a grant token are exactly the set THREAD-001 accounts for" do
86 89
    assert_exact_set(
test/openagents_web/live/thread_index_live_test.exs added +47

@@ -0,0 +1,47 @@

1
defmodule OpenAgentsWeb.ThreadIndexLiveTest do
2
  use OpenAgentsWeb.ConnCase, async: true
3
4
  import Phoenix.LiveViewTest
5
6
  alias OpenAgents.Threads
7
8
  defp signed_in(conn, user), do: Plug.Test.init_test_session(conn, %{"user_id" => user.id})
9
10
  test "owner sees own threads and nobody else's", %{conn: conn} do
11
    owner = github_user("thread-index-owner")
12
    other = github_user("thread-index-other")
13
14
    {:ok, mine} = Threads.open(owner, "List my work")
15
    {:ok, theirs} = Threads.open(other, "Somebody else's work")
16
17
    {:ok, view, _html} = live(signed_in(conn, owner), ~p"/threads")
18
19
    assert has_element?(view, "#threads-table")
20
    assert has_element?(view, "#thread-link-#{mine.id}")
21
    refute has_element?(view, "#thread-link-#{theirs.id}")
22
  end
23
24
  test "a row carries status and event count without reading the transcript", %{conn: conn} do
25
    owner = github_user("thread-index-shell")
26
    {:ok, thread} = Threads.open(owner, "Shell projection")
27
    {:ok, _updated} = Threads.record_event(thread, "turn.user", %{"text" => "hi"})
28
29
    {:ok, view, _html} = live(signed_in(conn, owner), ~p"/threads")
30
31
    assert view |> element("#threads-#{thread.id}") |> render() =~ "open"
32
    assert view |> element("#threads-#{thread.id}") |> render() =~ ">2<"
33
  end
34
35
  test "an account with no threads sees the empty state", %{conn: conn} do
36
    owner = github_user("thread-index-empty")
37
38
    {:ok, view, _html} = live(signed_in(conn, owner), ~p"/threads")
39
40
    assert has_element?(view, "#threads-empty")
41
    refute has_element?(view, "#threads-table")
42
  end
43
44
  test "anonymous browser is redirected", %{conn: conn} do
45
    assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/threads")
46
  end
47
end
test/openagents_web/live/thread_show_live_test.exs added +115

@@ -0,0 +1,115 @@

1
defmodule OpenAgentsWeb.ThreadShowLiveTest do
2
  use OpenAgentsWeb.ConnCase, async: true
3
4
  import Phoenix.LiveViewTest
5
6
  alias OpenAgents.Threads
7
8
  defp signed_in(conn, user), do: Plug.Test.init_test_session(conn, %{"user_id" => user.id})
9
10
  defp event_id(thread, event_type) do
11
    thread
12
    |> Threads.list_events()
13
    |> Enum.find(&(&1.event_type == event_type))
14
    |> Map.fetch!(:id)
15
  end
16
17
  test "renders each event kind of the vocabulary typed", %{conn: conn} do
18
    owner = github_user("thread-show-kinds")
19
    {:ok, thread} = Threads.open(owner, "Render the vocabulary")
20
21
    {:ok, thread} =
22
      Threads.record_event(thread, "turn.user", %{"text" => "Fix the bug", "steered" => true})
23
24
    {:ok, thread} =
25
      Threads.record_event(thread, "turn.reasoning", %{"text" => "The bug is in the parser."})
26
27
    {:ok, thread} =
28
      Threads.record_event(thread, "tool.ran", %{
29
        "tool" => "read_file",
30
        "status" => "ok",
31
        "arguments" => %{"path" => "lib/parser.ex"},
32
        "result" => "defmodule Parser do"
33
      })
34
35
    {:ok, thread} =
36
      Threads.record_event(thread, "turn.assistant", %{
37
        "text" => "Fixed. The parser **now** handles it.",
38
        "usage" => %{"input_tokens" => 100, "output_tokens" => 25, "total_tokens" => 125}
39
      })
40
41
    {:ok, view, _html} = live(signed_in(conn, owner), ~p"/threads/#{thread.id}")
42
43
    user_id = event_id(thread, "turn.user")
44
45
    assert view |> element("#events-#{user_id}[data-kind='turn.user']") |> render() =~
46
             "Fix the bug"
47
48
    assert view |> element("#events-#{user_id}") |> render() =~ "steered"
49
50
    reasoning_id = event_id(thread, "turn.reasoning")
51
52
    assert view |> element("#events-#{reasoning_id}[data-kind='turn.reasoning']") |> render() =~
53
             "The bug is in the parser."
54
55
    assert has_element?(view, "#events-#{reasoning_id} details")
56
57
    tool_id = event_id(thread, "tool.ran")
58
    tool_row = view |> element("#events-#{tool_id}[data-kind='tool.ran']") |> render()
59
    assert tool_row =~ "read_file"
60
    assert tool_row =~ "lib/parser.ex"
61
    assert tool_row =~ "defmodule Parser do"
62
    assert tool_row =~ "ok"
63
64
    assistant_id = event_id(thread, "turn.assistant")
65
66
    assistant_row =
67
      view |> element("#events-#{assistant_id}[data-kind='turn.assistant']") |> render()
68
69
    assert assistant_row =~ "<strong>now</strong>"
70
    assert assistant_row =~ "125 total"
71
  end
72
73
  test "an unknown event type renders as a neutral raw row", %{conn: conn} do
74
    owner = github_user("thread-show-unknown")
75
    {:ok, thread} = Threads.open(owner, "Survive the unknown")
76
77
    {:ok, thread} =
78
      Threads.record_event(thread, "plugin.custom", %{"whatever" => ["shape", 1, true]})
79
80
    # A typed event whose payload misses its text degrades the same way.
81
    {:ok, thread} = Threads.record_event(thread, "turn.user", %{"no_text" => true})
82
83
    {:ok, view, _html} = live(signed_in(conn, owner), ~p"/threads/#{thread.id}")
84
85
    unknown_id = event_id(thread, "plugin.custom")
86
87
    assert view |> element("#events-#{unknown_id}[data-kind='plugin.custom']") |> render() =~
88
             "plugin.custom"
89
90
    textless_id = event_id(thread, "turn.user")
91
    assert view |> element("#events-#{textless_id}") |> render() =~ "no_text"
92
  end
93
94
  test "another account's thread id is a plain 404", %{conn: conn} do
95
    owner = github_user("thread-show-owner")
96
    intruder = github_user("thread-show-intruder")
97
    {:ok, thread} = Threads.open(owner, "Private work")
98
99
    assert_raise OpenAgentsWeb.PublicNotFoundError, fn ->
100
      live(signed_in(conn, intruder), ~p"/threads/#{thread.id}")
101
    end
102
  end
103
104
  test "an unknown id is the same 404", %{conn: conn} do
105
    viewer = github_user("thread-show-unknown-id")
106
107
    assert_raise OpenAgentsWeb.PublicNotFoundError, fn ->
108
      live(signed_in(conn, viewer), ~p"/threads/#{Ecto.UUID.generate()}")
109
    end
110
111
    assert_raise OpenAgentsWeb.PublicNotFoundError, fn ->
112
      live(signed_in(conn, viewer), ~p"/threads/not-a-uuid")
113
    end
114
  end
115
end
test/openagents_web/live/thread_show_updates_test.exs added +59

@@ -0,0 +1,59 @@

1
defmodule OpenAgentsWeb.ThreadShowUpdatesTest do
2
  # async: false — the test broadcasts through the shared PubSub into a
3
  # LiveView process, following the other *_updates_test files.
4
  use OpenAgentsWeb.ConnCase, async: false
5
6
  import Phoenix.LiveViewTest
7
8
  alias OpenAgents.Threads
9
10
  defp signed_in(conn, user), do: Plug.Test.init_test_session(conn, %{"user_id" => user.id})
11
12
  test "a recorded event streams into the open transcript in order", %{conn: conn} do
13
    owner = github_user("thread-show-live")
14
    {:ok, thread} = Threads.open(owner, "Watch it live")
15
16
    {:ok, view, _html} = live(signed_in(conn, owner), ~p"/threads/#{thread.id}")
17
18
    {:ok, thread} = Threads.record_event(thread, "turn.user", %{"text" => "streamed in"})
19
    {:ok, thread} = Threads.record_event(thread, "turn.assistant", %{"text" => "and answered"})
20
21
    # Local PubSub dispatch sends before returning, so the view's mailbox
22
    # holds both events; get_state synchronizes past them.
23
    _ = :sys.get_state(view.pid)
24
25
    [user_event, assistant_event] =
26
      thread
27
      |> Threads.list_events()
28
      |> Enum.filter(&(&1.event_type in ["turn.user", "turn.assistant"]))
29
30
    assert view |> element("#events-#{user_event.id}") |> render() =~ "streamed in"
31
    assert view |> element("#events-#{assistant_event.id}") |> render() =~ "and answered"
32
33
    # thread.opened + the two appends.
34
    assert view |> element("#thread-event-count") |> render() =~ ">3<"
35
36
    # Order: the ids are monotonic and the DOM keeps append order.
37
    html = render(view)
38
39
    assert :binary.match(html, "events-#{user_event.id}") <
40
             :binary.match(html, "events-#{assistant_event.id}")
41
  end
42
43
  test "an event already in the snapshot is not doubled by its broadcast", %{conn: conn} do
44
    owner = github_user("thread-show-dedup")
45
    {:ok, thread} = Threads.open(owner, "Dedup by id")
46
    {:ok, thread} = Threads.record_event(thread, "turn.user", %{"text" => "snapshotted"})
47
48
    {:ok, view, _html} = live(signed_in(conn, owner), ~p"/threads/#{thread.id}")
49
50
    [event] = Enum.filter(Threads.list_events(thread), &(&1.event_type == "turn.user"))
51
52
    # Replay the broadcast the view may have buffered during mount.
53
    send(view.pid, {:thread_event, event})
54
    _ = :sys.get_state(view.pid)
55
56
    assert view |> element("#thread-event-count") |> render() =~ ">2<"
57
    assert has_element?(view, "#events-#{event.id}")
58
  end
59
end

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