Add GLM promotion and Coder changelog

71d2e5c9b0ff · AtlantisPleb · · parent 13c21a41a546

Add GLM promotion and Coder changelog

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 468 · 2026-08-27T04:06:58.254039Z
built
14 modules in 122.6 s
deployed
live · 14 modules on 3 nodes · push→live —
deployed
needs_rolling_replace · 14 modules on 0 nodes · push→live —

Changed files

  • modified INVARIANTS.md
  • modified config/config.exs
  • modified config/test.exs
  • modified docs/2026-08-21-posthog-integration-runbook.md
  • modified lib/openagents/inference/models.ex
  • modified lib/openagents/inference/pricing.ex
  • modified lib/openagents_web/controllers/changelog_controller.ex
  • modified lib/openagents_web/controllers/inference_proxy_controller.ex
  • added lib/openagents_web/controllers/legacy_changelog_controller.ex
  • modified lib/openagents_web/docs_catalog.ex
  • modified lib/openagents_web/live/home_live.ex
  • modified lib/openagents_web/router.ex
  • modified priv/docs/changelog.md
  • modified priv/docs/commits.md
  • modified priv/docs/welcome.md
  • modified test/openagents/inference/models_test.exs
  • modified test/openagents/inference/pricing_test.exs
  • modified test/openagents/inference_test.exs
  • modified test/openagents/threads_test.exs
  • modified test/openagents_web/controllers/inference_proxy_controller_test.exs
  • modified test/openagents_web/controllers/thread_controller_test.exs
  • modified test/openagents_web/home_controller_test.exs
  • modified test/openagents_web/live/changelog_live_test.exs
  • modified test/openagents_web/live/home_dashboard_live_test.exs
  • modified test/openagents_web/live/home_live_updates_test.exs
  • modified test/openagents_web/live/network_status_live_test.exs
  • modified test/openagents_web/live/thread_show_live_test.exs
  • modified test/openagents_web/transparency_surface_test.exs

Diff

28 files changed, +322 -346

INVARIANTS.md modified +1 -1

@@ -4345,7 +4345,7 @@ published key set included), and `OpenAgentsWeb.NetworkStatusLiveTest`.

4345 4345
4346 4346
Status: Current
4347 4347
4348
The public transparency surfaces — `/changelog`, `/api/changelog`, and the
4348
The public transparency surfaces — `/api/changelog` and the
4349 4349
forge web UI (`/<owner>/<repo>`, `/<owner>/<repo>/commit/:sha`,
4350 4350
`/<owner>/<repo>/tree/:ref/*path`, `/<owner>/<repo>/blob/:ref/*path` —
4351 4351
addressed exactly like the GitHub URLs
config/config.exs modified +14 -7

@@ -222,16 +222,23 @@ config :openagents,

222 222
      # model reasons against this allowance, so a small one is spent thinking
223 223
      # and the caller is handed a truncated answer on a 200.
224 224
      max_output: 131_000,
225
      # Placeholder: list price ($0.15 in, $0.50 out, $0.03 cached in, per
226
      # million tokens), not the half-price offer running until 2026-09-09
227
      # 16:00 UTC. Rates an operator has not declared, so nothing may bill from
228
      # them until `source` says `:declared`.
225
      # The declared promotion makes this lane free until the exclusive UTC
226
      # cutoff, after which pricing automatically returns to the regular table
227
      # without another deployment.
229 228
      pricing: %{
230
        id: "placeholder.glm-5.3-flash.v1",
231
        source: :placeholder,
229
        id: "declared.glm-5.3-flash.v1",
230
        source: :declared,
232 231
        input_per_million_tokens: 150_000,
233 232
        output_per_million_tokens: 500_000,
234
        cached_input_per_million_tokens: 30_000
233
        cached_input_per_million_tokens: 30_000,
234
        promotion: %{
235
          id: "declared.glm-5.3-flash.free-through-2026-08-31.v1",
236
          source: :declared,
237
          ends_at: ~U[2026-09-01 00:00:00Z],
238
          input_per_million_tokens: 0,
239
          output_per_million_tokens: 0,
240
          cached_input_per_million_tokens: 0
241
        }
235 242
      }
236 243
    },
237 244
    %{
config/test.exs modified +5

@@ -6,6 +6,11 @@ config :openagents, :repository_provisioner_enabled, false

6 6
7 7
config :openagents, :runtime_environment, :test
8 8
9
# Pin pricing at the promotion's exclusive cutoff so the general credit suite
10
# continues to exercise paid-default behavior. Promotion boundary tests pass
11
# explicit instants to `OpenAgents.Inference.Pricing.effective_pricing/2`.
12
config :openagents, :pricing_now, ~U[2026-09-01 00:00:00Z]
13
9 14
# Test fixtures create the forum boards each test needs; the seed migration
10 15
# stays out of their way. See SeedGeneralForumBoard.
11 16
config :openagents, :seed_forum_boards, false
docs/2026-08-21-posthog-integration-runbook.md modified +3

@@ -228,6 +228,9 @@ Chat and delegated work:

228 228
| `chat_stream_chunk` | streaming assistant deltas, throttled to one event per second per stream | `conversation_id`, `modality` |
229 229
| `chat_tool_called` | `Turns.TurnServer` tool request; `tool_call_started` in `Chat.AccountTurns` | `tool_name`, `turn_id`, `conversation_id`; never arguments |
230 230
| `chat_tokens_used` | once per turn at terminal state in `Turns.TurnServer` and `Chat.AccountTurns` | `input_tokens`, `output_tokens`, `model`, `provider`, `conversation_id`, `turn_id`, `outcome` |
231
| `inference_model_selected` | inference proxy after grant and catalog admission, before the provider call | requested, granted, selected, provider, and provider model IDs; availability; substitution policy; effective pricing table, basis, rates, and promotion cutoff; request shape counts; grant budget ceilings and fence types; never bearer material, prompts, instructions, tool arguments, or provider credentials |
232
| `inference_model_served` | successful inference proxy call | all selection properties plus effective served model, disclosure state, outcome, and whether the provider reported usage |
233
| `inference_model_failed` | failed inference proxy provider call | all selection properties plus outcome, sanitized reason code, upstream status, and whether the provider reported usage |
231 234
| `chat_turn_failed` | non-completed terminal turn in `ChatLive`; failed or cancelled run in `Chat.AccountTurns` | `reason`, `outcome`, `conversation_id`, `turn_id` |
232 235
| `chat_voice_started` / `chat_voice_ended` | voice session lifecycle broadcasts in `ChatLive` | `conversation_id`; end adds `outcome`, `duration_ms` |
233 236
| `memory_saved` | `ProfileMemory.remember_explicit` | `disposition`: `stored`, `already_active` |
lib/openagents/inference/models.ex modified +22 -8

@@ -190,26 +190,40 @@ defmodule OpenAgents.Inference.Models do

190 190
    default_id = default_id()
191 191
192 192
    Enum.map(all(), fn model ->
193
      pricing = Pricing.effective_pricing(model)
194
193 195
      base = %{
194 196
        "id" => model.id,
195 197
        "provider" => Atom.to_string(model.provider),
196 198
        "context_window" => model.context_window,
197 199
        "max_output" => model.max_output,
198 200
        "availability" => availability(model),
199
        "pricing_basis" => Pricing.basis_of(model.pricing),
201
        "pricing_basis" => Pricing.basis_of(pricing),
200 202
        "default" => model.id == default_id
201 203
      }
202 204
203
      case model.pricing do
204
        nil ->
205
          base
206
207
        %{} = pricing ->
208
          Map.put(base, "pricing", public_pricing(pricing))
209
      end
205
      base
206
      |> maybe_put_pricing(pricing)
207
      |> maybe_put_promotion(model, pricing)
210 208
    end)
211 209
  end
212 210
211
  defp maybe_put_pricing(base, nil), do: base
212
  defp maybe_put_pricing(base, pricing), do: Map.put(base, "pricing", public_pricing(pricing))
213
214
  defp maybe_put_promotion(base, model, pricing) do
215
    case Pricing.promotion_ends_at(model) do
216
      %DateTime{} = ends_at ->
217
        Map.put(base, "pricing_promotion", %{
218
          "active" => Pricing.pricing_id(pricing) != Pricing.pricing_id(model.pricing),
219
          "ends_at" => DateTime.to_iso8601(ends_at)
220
        })
221
222
      nil ->
223
        base
224
    end
225
  end
226
213 227
  defp public_pricing(pricing) do
214 228
    base = %{
215 229
      "id" => Pricing.pricing_id(pricing),
lib/openagents/inference/pricing.ex modified +60 -9

@@ -7,8 +7,8 @@ defmodule OpenAgents.Inference.Pricing do

7 7
  `gpt-5.6-luna` admitted and its rates never entered, so a surface that read a
8 8
  missing cost as zero would have shown `$0.00` beside a session that spent
9 9
  real money — and shown it in the same typeface as a figure that was measured.
10
  Every admitted model carries rates now, and none of them is declared, so the
11
  live case for a selectable model is `provisional`. `unpriced` is not dead
10
  Every admitted model carries rates now. A time-bounded promotion can replace
11
  a model's regular table until its exclusive UTC cutoff. `unpriced` is not dead
12 12
  code: a call the gateway's fallback chain answers with a model the catalog
13 13
  does not admit reaches it, and so would any entry added without rates.
14 14

@@ -16,8 +16,9 @@ defmodule OpenAgents.Inference.Pricing do

16 16
  `pricing_id` naming the rate table it was priced against, and the id resolves
17 17
  to one of three bases:
18 18
19
    * `declared` — the operator entered the provider's published rates. This is
20
      the only basis anything may bill from (`billable?/1`).
19
    * `declared` — the operator entered the provider's published rates or a
20
      promotional zero rate. This is the only basis anything may bill from
21
      (`billable?/1`).
21 22
    * `provisional` — the deployment carries rates that were written to make
22 23
      the system run rather than read off a provider's price page, or rates
23 24
      whose table is unnamed. A cost is computed and labelled; no bill may

@@ -43,6 +44,40 @@ defmodule OpenAgents.Inference.Pricing do

43 44
  @unpriced "unpriced"
44 45
  @unattributed "unattributed"
45 46
47
  @doc "The effective rate table for a model at the given UTC instant."
48
  @spec effective_pricing(map(), DateTime.t()) :: map() | nil
49
  def effective_pricing(model, now \\ pricing_now())
50
51
  def effective_pricing(%{pricing: nil}, _now), do: nil
52
53
  def effective_pricing(%{pricing: pricing}, %DateTime{} = now) do
54
    case Map.get(pricing, :promotion) do
55
      %{ends_at: %DateTime{} = ends_at} = promotion ->
56
        if DateTime.compare(now, ends_at) == :lt do
57
          Map.delete(promotion, :ends_at)
58
        else
59
          Map.delete(pricing, :promotion)
60
        end
61
62
      _absent ->
63
        Map.delete(pricing, :promotion)
64
    end
65
  end
66
67
  @doc "Whether a model's promotional rate table is active now."
68
  @spec promotion_active?(map(), DateTime.t()) :: boolean()
69
  def promotion_active?(model, now \\ pricing_now()) do
70
    match?(%{pricing: %{promotion: %{ends_at: %DateTime{}}}}, model) and
71
      pricing_id(effective_pricing(model, now)) != pricing_id(model.pricing)
72
  end
73
74
  @doc "The exclusive UTC cutoff for a model's promotion, when configured."
75
  @spec promotion_ends_at(map()) :: DateTime.t() | nil
76
  def promotion_ends_at(%{pricing: %{promotion: %{ends_at: %DateTime{} = ends_at}}}),
77
    do: ends_at
78
79
  def promotion_ends_at(_model), do: nil
80
46 81
  @doc "The `pricing_id` written for a lane with no declared rates."
47 82
  @spec unpriced() :: String.t()
48 83
  def unpriced, do: @unpriced

@@ -61,11 +96,11 @@ defmodule OpenAgents.Inference.Pricing do

61 96
62 97
  @doc "The basis for a model, by catalog id or resolved model."
63 98
  @spec basis(map() | String.t() | nil) :: String.t()
64
  def basis(%{pricing: pricing}), do: basis_of(pricing)
99
  def basis(%{pricing: _pricing} = model), do: model |> effective_pricing() |> basis_of()
65 100
66 101
  def basis(model_id) do
67 102
    case Models.fetch(model_id) do
68
      {:ok, model} -> basis_of(model.pricing)
103
      {:ok, model} -> basis(model)
69 104
      :error -> @unpriced
70 105
    end
71 106
  end

@@ -84,7 +119,7 @@ defmodule OpenAgents.Inference.Pricing do

84 119
  @spec pricing_id_for(String.t() | nil) :: String.t()
85 120
  def pricing_id_for(model_id) do
86 121
    case Models.fetch(model_id) do
87
      {:ok, model} -> pricing_id(model.pricing)
122
      {:ok, model} -> model |> effective_pricing() |> pricing_id()
88 123
      :error -> @unpriced
89 124
    end
90 125
  end

@@ -105,7 +140,7 @@ defmodule OpenAgents.Inference.Pricing do

105 140
  def price(usage, model_id) when is_map(usage) do
106 141
    pricing =
107 142
      case Models.fetch(model_id) do
108
        {:ok, model} -> model.pricing
143
        {:ok, model} -> effective_pricing(model)
109 144
        :error -> nil
110 145
      end
111 146

@@ -184,10 +219,22 @@ defmodule OpenAgents.Inference.Pricing do

184 219
  # DEREFERENCEABLE USAGE RECORD is the contract.
185 220
  defp declared_id?(id) do
186 221
    Enum.any?(Models.all(), fn model ->
187
      pricing_id(model.pricing) == id and basis_of(model.pricing) == "declared"
222
      Enum.any?(rate_tables(model.pricing), fn pricing ->
223
        pricing_id(pricing) == id and basis_of(pricing) == "declared"
224
      end)
188 225
    end)
189 226
  end
190 227
228
  defp rate_tables(nil), do: []
229
230
  defp rate_tables(pricing) do
231
    [Map.delete(pricing, :promotion)] ++
232
      case Map.get(pricing, :promotion) do
233
        %{} = promotion -> [Map.delete(promotion, :ends_at)]
234
        _absent -> []
235
      end
236
  end
237
191 238
  @doc """
192 239
  Whether a stored usage record may be billed from.
193 240

@@ -205,4 +252,8 @@ defmodule OpenAgents.Inference.Pricing do

205 252
  defp integer(value) when is_integer(value), do: value
206 253
  defp integer(value) when is_float(value), do: trunc(value)
207 254
  defp integer(_value), do: 0
255
256
  defp pricing_now do
257
    Application.get_env(:openagents, :pricing_now, DateTime.utc_now())
258
  end
208 259
end
lib/openagents_web/controllers/changelog_controller.ex modified +3 -4

@@ -1,10 +1,9 @@

1 1
defmodule OpenAgentsWeb.ChangelogController do
2 2
  @moduledoc """
3 3
  `GET /api/changelog` — the machine-readable changelog
4
  (schema `openagents.changelog.v1`), the superset of the public `/changelog`
5
  page including receipt row ids so agents can cross-check every claim
6
  against the receipt chain. Same posture as `/api/status`: public,
7
  read-only, bounded, no identity state.
4
  (schema `openagents.changelog.v1`), including receipt row ids so clients can
5
  cross-check every claim against the receipt chain. Same posture as
6
  `/api/status`: public, read-only, bounded, no identity state.
8 7
  """
9 8
10 9
  use OpenAgentsWeb, :controller
lib/openagents_web/controllers/inference_proxy_controller.ex modified +13 -1

@@ -39,7 +39,7 @@ defmodule OpenAgentsWeb.InferenceProxyController do

39 39
40 40
  alias OpenAgents.Analytics
41 41
  alias OpenAgents.Inference
42
  alias OpenAgents.Inference.Models
42
  alias OpenAgents.Inference.{Models, Pricing}
43 43
  alias OpenAgents.Providers.{Request, ToolDefinition, ToolOutput}
44 44
45 45
  def create(conn, _params) do

@@ -289,6 +289,8 @@ defmodule OpenAgentsWeb.InferenceProxyController do

289 289
  # it is not an event property.
290 290
  defp selection_properties(grant, model, request, body) do
291 291
    requested = Map.get(body, "model")
292
    pricing = Pricing.effective_pricing(model)
293
    promotion_ends_at = Pricing.promotion_ends_at(model)
292 294
293 295
    %{
294 296
      "selection_schema" => "inference_model_selection.v1",

@@ -299,6 +301,16 @@ defmodule OpenAgentsWeb.InferenceProxyController do

299 301
      "selected_model" => model.id,
300 302
      "provider" => Atom.to_string(model.provider),
301 303
      "provider_model" => model.provider_model,
304
      "pricing_id" => Pricing.pricing_id(pricing),
305
      "pricing_basis" => Pricing.basis_of(pricing),
306
      "pricing_promotion_active" =>
307
        Pricing.pricing_id(pricing) != Pricing.pricing_id(model.pricing),
308
      "pricing_promotion_ends_at" =>
309
        if(promotion_ends_at, do: DateTime.to_iso8601(promotion_ends_at), else: nil),
310
      "input_price_per_million_tokens" => pricing && pricing.input_per_million_tokens,
311
      "output_price_per_million_tokens" => pricing && pricing.output_per_million_tokens,
312
      "cached_input_price_per_million_tokens" =>
313
        pricing && Map.get(pricing, :cached_input_per_million_tokens),
302 314
      "model_availability" => Models.availability(model),
303 315
      "model_available" => Models.available?(model),
304 316
      "adapter_substitutable" => substitutable?(model.adapter),
lib/openagents_web/controllers/legacy_changelog_controller.ex added +5

@@ -0,0 +1,5 @@

1
defmodule OpenAgentsWeb.LegacyChangelogController do
2
  use OpenAgentsWeb, :controller
3
4
  def index(conn, _params), do: redirect(conn, to: ~p"/docs/changelog")
5
end
lib/openagents_web/docs_catalog.ex modified +1 -1

@@ -222,7 +222,7 @@ defmodule OpenAgentsWeb.DocsCatalog do

222 222
    %{
223 223
      title: "Transparency",
224 224
      items: [
225
        %{slug: "changelog", title: "Changelog", icon: "text", route: "/changelog"},
225
        %{slug: "changelog", title: "Changelog", icon: "text", route: "/docs/changelog"},
226 226
        %{slug: "status", title: "Status", icon: "check-circle", route: "/status"},
227 227
        %{slug: "leaderboard", title: "Leaderboard", icon: "star", route: "/leaderboard"}
228 228
      ]
lib/openagents_web/live/home_live.ex modified +4 -78

@@ -9,8 +9,7 @@ defmodule OpenAgentsWeb.HomeLive do

9 9
  Signed in, a marketing pitch is the wrong thing to show: the visitor has
10 10
  already been sold, and what they want is the state of the work. The same
11 11
  route renders a dashboard -- repositories, open issues, projects, what the
12
  forum is discussing, and what shipped recently -- the way a code host's home
13
  does.
12
  forum is discussing -- the way a code host's home does.
14 13
15 14
  The dashboard describes the viewer's work across every repository they can
16 15
  read, not one repository chosen for them, and it says so by reading through

@@ -24,8 +23,8 @@ defmodule OpenAgentsWeb.HomeLive do

24 23
25 24
  It is also current. Every panel names a publisher and re-reads when it hears
26 25
  one, so an issue opened in another tab moves the count and the feed without a
27
  refresh, and the same holds for a project, a repository, a forum post, and a
28
  changelog entry. The announcements carry ids and nothing else: each panel
26
  refresh, and the same holds for a project, a repository, and a forum post.
27
  The announcements carry ids and nothing else: each panel
29 28
  re-reads through the same authorized read that filled it at mount, so a
30 29
  viewer can never be handed a row -- or a row counted into a number -- the
31 30
  database would have refused them. Bursts collapse into one re-read of only

@@ -38,7 +37,6 @@ defmodule OpenAgentsWeb.HomeLive do

38 37
  use OpenAgentsWeb, :live_view
39 38
40 39
  alias OpenAgents.Accounts
41
  alias OpenAgents.Changelog
42 40
  alias OpenAgents.Forum
43 41
  alias OpenAgents.Issues
44 42
  alias OpenAgents.Projects

@@ -46,8 +44,6 @@ defmodule OpenAgentsWeb.HomeLive do

46 44
  alias OpenAgentsWeb.LiveRefresh
47 45
  alias OpenAgentsWeb.UI.Landing
48 46
49
  @repo "openagents.com"
50
51 47
  # The one line the landing page asks a reader to run. It is the command
52 48
  # `priv/docs/install-cli.md` documents and the script
53 49
  # `priv/static/install.sh` serves, so the page cannot advertise an installer

@@ -57,7 +53,6 @@ defmodule OpenAgentsWeb.HomeLive do

57 53
  @feed_limit 8
58 54
  @project_limit 6
59 55
  @post_limit 6
60
  @changelog_limit 5
61 56
62 57
  @impl true
63 58
  def mount(params, _session, socket) do

@@ -105,7 +100,6 @@ defmodule OpenAgentsWeb.HomeLive do

105 100
    :ok = Repositories.subscribe_all_projects()
106 101
    :ok = Repositories.subscribe_repository_changes()
107 102
    :ok = Forum.subscribe_posts()
108
    :ok = Changelog.subscribe()
109 103
  end
110 104
111 105
  @impl true

@@ -128,11 +122,7 @@ defmodule OpenAgentsWeb.HomeLive do

128 122
  def handle_info(:live_refresh, socket),
129 123
    do: {:noreply, LiveRefresh.run(socket, &refresh_panel/2)}
130 124
131
  def handle_info(message, socket) do
132
    if Changelog.ledger_event?(message),
133
      do: {:noreply, mark_stale(socket, :changelog)},
134
      else: {:noreply, socket}
135
  end
125
  def handle_info(_message, socket), do: {:noreply, socket}
136 126
137 127
  # One re-armed timer, shared with every other live surface through
138 128
  # `LiveRefresh`, so a burst of announcements costs one re-read rather than

@@ -143,7 +133,6 @@ defmodule OpenAgentsWeb.HomeLive do

143 133
  defp refresh_panel(socket, :issues), do: assign_issues(socket)
144 134
  defp refresh_panel(socket, :projects), do: assign_projects(socket)
145 135
  defp refresh_panel(socket, :posts), do: assign_posts(socket)
146
  defp refresh_panel(socket, :changelog), do: assign_changelog(socket, refresh: true)
147 136
148 137
  defp refresh_panel(socket, :repositories) do
149 138
    socket.assigns.changed_repositories

@@ -178,7 +167,6 @@ defmodule OpenAgentsWeb.HomeLive do

178 167
    |> assign_issues()
179 168
    |> assign_projects()
180 169
    |> assign_posts()
181
    |> assign_changelog()
182 170
    |> assign_repositories()
183 171
  end
184 172

@@ -223,14 +211,6 @@ defmodule OpenAgentsWeb.HomeLive do

223 211
    )
224 212
  end
225 213
226
  # Mount reads the shared cache, because every signed-in page load would
227
  # otherwise rebuild the projection and the cache exists to stop exactly that.
228
  # A live refresh bypasses it, because a rail told the ledger moved and then
229
  # handed the cached answer would render the state it was told had changed.
230
  defp assign_changelog(socket, opts \\ []) do
231
    assign(socket, :changelog, changelog_entries(opts))
232
  end
233
234 214
  defp assign_repositories(socket) do
235 215
    repositories = Repositories.list_visible_repositories(socket.assigns.current_user)
236 216

@@ -239,37 +219,6 @@ defmodule OpenAgentsWeb.HomeLive do

239 219
    |> stream(:repositories, repositories)
240 220
  end
241 221
242
  # The ledger is a bounded public projection and can legitimately refuse. An
243
  # empty rail is the honest answer; the home page should not crash over it.
244
  #
245
  # Its agent-layer rows carry no authored note, which is what left the rail
246
  # rendering a column of bare relative times. A row states what it is or it
247
  # does not render.
248
  defp changelog_entries(opts) do
249
    case Changelog.timeline(@repo, opts) do
250
      {:ok, rows} ->
251
        rows
252
        |> Enum.map(&%{entry_at: &1.entry_at, summary: changelog_summary(&1)})
253
        |> Enum.reject(&is_nil(&1.summary))
254
        |> Enum.take(@changelog_limit)
255
256
      {:error, _reason} ->
257
        []
258
    end
259
  end
260
261
  defp changelog_summary(%{summary: summary}) when is_binary(summary) do
262
    case String.trim(summary) do
263
      "" -> nil
264
      trimmed -> trimmed
265
    end
266
  end
267
268
  defp changelog_summary(%{short_sha: short_sha}) when is_binary(short_sha),
269
    do: "Receipted deploy of #{short_sha}"
270
271
  defp changelog_summary(_row), do: nil
272
273 222
  @impl true
274 223
  def render(%{current_user: user} = assigns) when not is_nil(user) do
275 224
    ~H"""

@@ -455,25 +404,6 @@ defmodule OpenAgentsWeb.HomeLive do

455 404
              No posts yet on the boards you can read.
456 405
            </p>
457 406
          </section>
458
459
          <section class="panel" aria-labelledby="dashboard-changelog">
460
            <header class="panel__header">
461
              <h2 id="dashboard-changelog" class="panel__title">Latest from the changelog</h2>
462
            </header>
463
464
            <ol :if={@changelog != []} class="changelog-rail">
465
              <li :for={entry <- @changelog}>
466
                <p class="changelog-rail__when">{relative_time(entry.entry_at)}</p>
467
                <p class="changelog-rail__summary">{entry.summary}</p>
468
              </li>
469
            </ol>
470
471
            <p :if={@changelog == []} class="panel__empty">Nothing recorded yet.</p>
472
473
            <.link navigate={~p"/changelog"} class="panel__more">
474
              View changelog <.icon name="arrow-right" />
475
            </.link>
476
          </section>
477 407
        </aside>
478 408
      </div>
479 409
    </Layouts.app>

@@ -581,9 +511,6 @@ defmodule OpenAgentsWeb.HomeLive do

581 511
            <.button navigate={~p"/docs"} variant={:ghost} size={:sm} class="hero__link">
582 512
              Read the docs <.icon name="chevron-right" />
583 513
            </.button>
584
            <.button navigate={~p"/changelog"} variant={:ghost} size={:sm} class="hero__link">
585
              Changelog <.icon name="chevron-right" />
586
            </.button>
587 514
            <.button
588 515
              navigate={~p"/OpenAgentsInc/openagents.com"}
589 516
              variant={:ghost}

@@ -708,7 +635,6 @@ defmodule OpenAgentsWeb.HomeLive do

708 635
            <.link navigate={~p"/models"}>Models</.link>
709 636
          </:column>
710 637
          <:column title="Transparency">
711
            <.link navigate={~p"/changelog"}>Changelog</.link>
712 638
            <.link navigate={~p"/status"}>Status</.link>
713 639
            <.link navigate={~p"/leaderboard"}>Leaderboard</.link>
714 640
          </:column>
lib/openagents_web/router.ex modified +1 -1

@@ -186,12 +186,12 @@ defmodule OpenAgentsWeb.Router do

186 186
    # canonical topic route. Unknown ids answer 404; the mirror is never read.
187 187
    get "/forum/topic/:id", LegacyForumController, :topic
188 188
    get "/forum/post/:id", LegacyForumController, :post
189
    get "/changelog", LegacyChangelogController, :index
189 190
190 191
    live_session :public,
191 192
      on_mount: [{OpenAgentsWeb.UserAuth, :mount_current_user}] do
192 193
      live "/", HomeLive, :index
193 194
      live "/status", NetworkStatusLive, :index
194
      live "/changelog", ChangelogLive, :index
195 195
      live "/leaderboard", LeaderboardLive, :index
196 196
      live "/coder", CoderLive, :index
197 197
priv/docs/changelog.md modified +52 -22

@@ -1,24 +1,54 @@

1 1
# Changelog
2 2
3
[The changelog](/changelog) lists every change to the application, in two
4
layers.
5
6
## Two layers
7
8
The top layer is a plain-language summary of what changed and why it matters.
9
Expanding an entry reveals the receipt chain underneath: the commit, the build,
10
the deploy, and the elapsed time from push to live.
11
12
Most changelogs ask you to trust a summary. This one lets you check it, which
13
is the entire point of publishing it.
14
15
## Verifying an entry
16
17
Each entry links to its commit. From the commit you can read the diff, and the
18
receipts tell you when that exact revision went live.
19
20
## What is not published
21
22
Disclosure is per-repository. An entry may show that a change happened and what
23
class it belonged to while withholding the diff, if that repository serves at a
24
lower level.
3
## Coder 0.1
4
5
Released August 2026.
6
7
Coder 0.1 brings interactive coding, model access, tools, delegation, and
8
durable session history into the OpenAgents CLI.
9
10
### Work in your terminal
11
12
- Stream Markdown responses, reasoning, tool status, and diffs in an interactive
13
  terminal interface. The interface includes scrollback, prompt history,
14
  multiline paste, and dropped image references.
15
- Read, write, edit, and search files. Run shell commands with bounded tool
16
  output.
17
- Resume prior threads from their server-side transcripts.
18
19
### Choose how inference runs
20
21
- Switch between Coder Flash and Coder Free with `Shift+Tab`. Coder displays the
22
  active lane below the composer.
23
- Use local Ollama models or a development server lane when you want inference
24
  to run outside the hosted lanes.
25
- Inspect the effective model and account balance, and retry transient provider
26
  failures without losing the turn.
27
28
### Delegate work
29
30
- Delegate to installed ACP-compatible coding agents, including Claude Code,
31
  Codex, and Devin.
32
- Run child tasks in parallel and keep their transcripts with the parent task.
33
- Discover installed skills and capabilities, and run delegated tools behind
34
  the shell safety gate.
35
36
### Control long-running work
37
38
- Use `/goal` to set an objective, inspect its status and budget, pause or resume
39
  it, and clear it when the work is complete.
40
- Cancel the current turn without clearing queued work or exiting Coder. Coder
41
  also cleans up active tools and child processes and settles canceled turns
42
  once.
43
- Sign in and out from the terminal while storing credentials in your operating
44
  system's credential store.
45
46
The release is covered by an interactive PTY test harness and a Cargo workspace
47
completion gate. Installed binaries report their published release version.
48
49
Read the closed work for the [native terminal interface](https://openagents.com/OpenAgentsInc/openagents/issues/117),
50
[first-class tools](https://openagents.com/OpenAgentsInc/openagents/issues/127),
51
[model lanes](https://openagents.com/OpenAgentsInc/openagents/issues/131),
52
[CLI consolidation](https://openagents.com/OpenAgentsInc/openagents/issues/137),
53
[ACP delegation](https://openagents.com/OpenAgentsInc/openagents/issues/72), and
54
[turn cancellation](https://openagents.com/OpenAgentsInc/openagents/issues/142).
priv/docs/commits.md modified +4 -2

@@ -11,10 +11,12 @@ hundreds of files shows the list and truncates the diffs.

11 11
## Trailers
12 12
13 13
Commits carry trailers identifying the agent session that produced them, where
14
one did. This is what lets a change be traced from the [changelog](/changelog)
14
one did. This is what lets a change be traced from the
15
[machine-readable changelog](/api/changelog)
15 16
back to the conversation that caused it.
16 17
17 18
## Deploy history
18 19
19 20
Where a commit reached the fleet, its receipt chain is visible from the
20
changelog: pushed, built, deployed, and how long each step took.
21
machine-readable changelog: pushed, built, deployed, and how long each step
22
took.
priv/docs/welcome.md modified +3 -3

@@ -18,9 +18,9 @@ renders files and commits in repositories that you can access.

18 18
[stacks](/docs/stacked-pull-requests) order several of them so each builds on
19 19
the one before it.
20 20
21
Three surfaces exist to show the system's own work rather than yours. The
22
[changelog](/changelog) lists every change with the receipt chain that took it
23
live. [Status](/status) reports fleet health. The
21
Three surfaces describe the system itself rather than your work. The
22
[changelog](/docs/changelog) presents product releases. [Status](/status)
23
reports fleet health. The
24 24
[leaderboard](/leaderboard) ranks contributors by tokens.
25 25
26 26
## What is not here yet
test/openagents/inference/models_test.exs modified +17 -10

@@ -1,7 +1,7 @@

1 1
defmodule OpenAgents.Inference.ModelsTest do
2 2
  use ExUnit.Case, async: true
3 3
4
  alias OpenAgents.Inference.Models
4
  alias OpenAgents.Inference.{Models, Pricing}
5 5
6 6
  test "the default is the catalog's first entry, served by that lane's adapter" do
7 7
    default = Models.default()

@@ -157,18 +157,25 @@ defmodule OpenAgents.Inference.ModelsTest do

157 157
      assert pricing["cached_input_per_million_tokens"] == 100_000
158 158
    end
159 159
160
    test "the default's rates are provisional, so nothing may bill from them" do
160
    test "the default publishes its currently effective rate table" do
161 161
      glm = Enum.find(Models.catalog(), &(&1["id"] == "glm-5.3-flash"))
162
      effective = Pricing.effective_pricing(Models.default())
162 163
163
      assert glm["pricing"]["id"] == "placeholder.glm-5.3-flash.v1"
164
      assert glm["pricing"]["input_per_million_tokens"] == 150_000
165
      assert glm["pricing"]["output_per_million_tokens"] == 500_000
166
      assert glm["pricing"]["cached_input_per_million_tokens"] == 30_000
164
      assert glm["pricing"]["id"] == effective.id
167 165
168
      # Read off the gateway's own listing, which still is not an operator
169
      # declaring them (METER-001).
170
      assert glm["pricing_basis"] == "provisional"
171
      assert glm["pricing"]["basis"] == "provisional"
166
      assert glm["pricing"]["input_per_million_tokens"] ==
167
               effective.input_per_million_tokens
168
169
      assert glm["pricing"]["output_per_million_tokens"] ==
170
               effective.output_per_million_tokens
171
172
      assert glm["pricing"]["cached_input_per_million_tokens"] ==
173
               effective.cached_input_per_million_tokens
174
175
      assert glm["pricing_basis"] == Pricing.basis_of(effective)
176
      assert glm["pricing"]["basis"] == Pricing.basis_of(effective)
177
      assert glm["pricing_promotion"]["ends_at"] == "2026-09-01T00:00:00Z"
178
      assert glm["pricing_promotion"]["active"] == Pricing.promotion_active?(Models.default())
172 179
    end
173 180
174 181
    test "the free router declares its zero price" do
test/openagents/inference/pricing_test.exs modified +38 -2

@@ -56,6 +56,38 @@ defmodule OpenAgents.Inference.PricingTest do

56 56
    end
57 57
  end
58 58
59
  describe "the GLM free promotion" do
60
    test "uses zero declared rates before the cutoff and regular rates at the cutoff" do
61
      {:ok, glm} = OpenAgents.Inference.Models.fetch("glm-5.3-flash")
62
63
      promotional = Pricing.effective_pricing(glm, ~U[2026-08-31 23:59:59.999999Z])
64
      regular = Pricing.effective_pricing(glm, ~U[2026-09-01 00:00:00Z])
65
66
      assert promotional.id == "declared.glm-5.3-flash.free-through-2026-08-31.v1"
67
      assert promotional.source == :declared
68
      assert promotional.input_per_million_tokens == 0
69
      assert promotional.output_per_million_tokens == 0
70
      assert promotional.cached_input_per_million_tokens == 0
71
      assert Pricing.promotion_active?(glm, ~U[2026-08-31 23:59:59Z])
72
73
      assert regular.id == "declared.glm-5.3-flash.v1"
74
      assert regular.source == :declared
75
      assert regular.input_per_million_tokens == 150_000
76
      refute Pricing.promotion_active?(glm, ~U[2026-09-01 00:00:00Z])
77
      assert Pricing.promotion_ends_at(glm) == ~U[2026-09-01 00:00:00Z]
78
    end
79
80
    test "the declared promotion remains billable as a zero-cost historical record" do
81
      usage = %{
82
        "pricing_id" => "declared.glm-5.3-flash.free-through-2026-08-31.v1",
83
        "estimated_cost_microusd" => 0
84
      }
85
86
      assert Pricing.usage_basis(usage) == "declared"
87
      assert Pricing.billable?(usage)
88
    end
89
  end
90
59 91
  describe "pricing one usage record" do
60 92
    test "an unpriced model writes no cost key — not a zero" do
61 93
      priced =

@@ -136,16 +168,20 @@ defmodule OpenAgents.Inference.PricingTest do

136 168
  end
137 169
138 170
  describe "the catalog this deployment actually ships" do
139
    test "only the free router claims declared zero rates" do
171
    test "declared tables publish the configured rates" do
140 172
      bases = Enum.map(OpenAgents.Inference.Models.catalog(), & &1["pricing_basis"])
141 173
142 174
      free_router =
143 175
        Enum.find(OpenAgents.Inference.Models.catalog(), &(&1["id"] == "openrouter/free"))
144 176
145
      assert Enum.count(bases, &(&1 == "declared")) == 1
177
      glm = Enum.find(OpenAgents.Inference.Models.catalog(), &(&1["id"] == "glm-5.3-flash"))
178
179
      assert Enum.count(bases, &(&1 == "declared")) >= 2
146 180
      assert "provisional" in bases
147 181
      assert free_router["pricing"]["input_per_million_tokens"] == 0
148 182
      assert free_router["pricing"]["output_per_million_tokens"] == 0
183
      assert glm["pricing"]["input_per_million_tokens"] == 150_000
184
      assert glm["pricing"]["output_per_million_tokens"] == 500_000
149 185
    end
150 186
151 187
    test "every shipped lane carries rates, so none reads as unpriced" do
test/openagents/inference_test.exs modified +4 -3

@@ -4,7 +4,7 @@ defmodule OpenAgents.InferenceTest do

4 4
5 5
  alias OpenAgents.Inference
6 6
  alias OpenAgents.Inference.Grant
7
  alias OpenAgents.Inference.Models
7
  alias OpenAgents.Inference.{Models, Pricing}
8 8
  alias OpenAgents.Machines
9 9
  alias OpenAgents.Repo
10 10
  alias OpenAgents.UnpricedLane

@@ -108,7 +108,8 @@ defmodule OpenAgents.InferenceTest do

108 108
      assert once.usage["input_tokens"] == 100
109 109
      assert once.usage["output_tokens"] == 40
110 110
      assert once.usage["total_tokens"] == 140
111
      assert once.usage["estimated_cost_microusd"] > 0
111
      assert once.usage["estimated_cost_microusd"] >= 0
112
      assert once.usage["pricing_id"] == Pricing.pricing_id_for(grant.model_id)
112 113
      assert once.usage["schema"] == "sarah.inference_grant_usage.v1"
113 114
114 115
      {:ok, twice} = Inference.record_usage(once, %{"input_tokens" => 10, "output_tokens" => 5})

@@ -204,7 +205,7 @@ defmodule OpenAgents.InferenceTest do

204 205
          "cache_read_input_tokens" => cache_read
205 206
        })
206 207
207
      pricing = Models.default().pricing
208
      pricing = Pricing.effective_pricing(Models.default())
208 209
209 210
      expected =
210 211
        ((input - cache_read) * pricing.input_per_million_tokens +
test/openagents/threads_test.exs modified +26 -11

@@ -7,7 +7,7 @@ defmodule OpenAgents.ThreadsTest do

7 7
  alias OpenAgents.Inference
8 8
  alias OpenAgents.Inference.Credit
9 9
  alias OpenAgents.Inference.Grant
10
  alias OpenAgents.Inference.Models
10
  alias OpenAgents.Inference.{Models, Pricing}
11 11
  alias OpenAgents.Repo
12 12
  alias OpenAgents.Threads
13 13
  alias OpenAgents.Threads.Event

@@ -558,7 +558,10 @@ defmodule OpenAgents.ThreadsTest do

558 558
      assert spent.call_count == 2
559 559
560 560
      {:ok, _child, child_grant, _token} =
561
        Threads.open_and_mint(user, "Child", parent_thread_id: parent.id)
561
        Threads.open_and_mint(user, "Child",
562
          parent_thread_id: parent.id,
563
          model: "gemini-3.7-flash"
564
        )
562 565
563 566
      assert child_grant.max_calls == 3
564 567
    end

@@ -583,19 +586,25 @@ defmodule OpenAgents.ThreadsTest do

583 586
      set_config(:thread_grant_max_calls, nil)
584 587
      set_config(:thread_grant_max_total_tokens, nil)
585 588
      user = owner("child-cost")
586
      {:ok, parent, grant, _token} = Threads.open_and_mint(user, "Parent")
587 589
588
      # Output tokens priced at the default lane's own output rate, so the
590
      {:ok, parent, grant, _token} =
591
        Threads.open_and_mint(user, "Parent", model: "gemini-3.7-flash")
592
593
      # Output tokens priced at the paid lane's own output rate, so the
589 594
      # arithmetic below follows the catalog rather than restating it.
590
      rate = Models.default().pricing.output_per_million_tokens
591
      output = 20_000
595
      {:ok, paid_model} = Models.fetch("gemini-3.7-flash")
596
      rate = Pricing.effective_pricing(paid_model).output_per_million_tokens
597
      output = 5_000
592 598
      cost = div(output * rate, 1_000_000)
593 599
594 600
      {:ok, spent} = Inference.record_usage(grant, %{"output_tokens" => output})
595 601
      assert spent.usage["estimated_cost_microusd"] == cost
596 602
597 603
      {:ok, _child, child_grant, _token} =
598
        Threads.open_and_mint(user, "Child", parent_thread_id: parent.id)
604
        Threads.open_and_mint(user, "Child",
605
          parent_thread_id: parent.id,
606
          model: "gemini-3.7-flash"
607
        )
599 608
600 609
      assert child_grant.max_cost_microusd == 100_000 - cost
601 610
    end

@@ -717,11 +726,12 @@ defmodule OpenAgents.ThreadsTest do

717 726
718 727
      spend = Threads.spend(thread)
719 728
720
      rate = Models.default().pricing.input_per_million_tokens
729
      pricing = Pricing.effective_pricing(Models.default())
730
      rate = pricing.input_per_million_tokens
721 731
722 732
      assert spend.cost.microusd == rate
723 733
      assert spend.cost.priced_microusd == rate
724
      assert spend.cost.basis == "provisional"
734
      assert spend.cost.basis == Pricing.basis_of(pricing)
725 735
      assert spend.cost.unpriced_models == []
726 736
    end
727 737

@@ -743,7 +753,9 @@ defmodule OpenAgents.ThreadsTest do

743 753
      assert spend.cost.microusd == nil
744 754
      # Nothing measured is thrown away — the priced half is still reported,
745 755
      # just not as the answer to "what did this cost".
746
      assert spend.cost.priced_microusd == Models.default().pricing.input_per_million_tokens
756
      assert spend.cost.priced_microusd ==
757
               Pricing.effective_pricing(Models.default()).input_per_million_tokens
758
747 759
      assert spend.cost.basis == "unpriced"
748 760
      assert spend.cost.unpriced_models == [unpriced]
749 761
    end

@@ -760,7 +772,10 @@ defmodule OpenAgents.ThreadsTest do

760 772
      spend = Threads.spend(thread)
761 773
762 774
      assert spend.grants == 2
763
      assert spend.cost.microusd == Models.default().pricing.input_per_million_tokens
775
776
      assert spend.cost.microusd ==
777
               Pricing.effective_pricing(Models.default()).input_per_million_tokens
778
764 779
      assert spend.cost.unpriced_calls == 0
765 780
    end
766 781
test/openagents_web/controllers/inference_proxy_controller_test.exs modified +5 -1

@@ -5,6 +5,7 @@ defmodule OpenAgentsWeb.InferenceProxyControllerTest do

5 5
  alias OpenAgents.Inference.Grant
6 6
  alias OpenAgents.Inference.Health
7 7
  alias OpenAgents.Inference.Models
8
  alias OpenAgents.Inference.Pricing
8 9
  alias OpenAgents.Machines
9 10
  alias OpenAgents.Providers.RecordingTestProvider
10 11
  alias OpenAgents.Repo

@@ -130,7 +131,8 @@ defmodule OpenAgentsWeb.InferenceProxyControllerTest do

130 131
    metered = Repo.get(Grant, grant.id)
131 132
    assert metered.call_count == 1
132 133
    assert metered.usage["total_tokens"] == 12
133
    assert metered.usage["estimated_cost_microusd"] > 0
134
    assert metered.usage["estimated_cost_microusd"] >= 0
135
    assert metered.usage["pricing_id"] == Pricing.pricing_id_for(default.id)
134 136
135 137
    assert_receive {:analytics, "inference_model_selected", distinct_id, selected}
136 138
    assert distinct_id =~ "visitor_"

@@ -139,6 +141,8 @@ defmodule OpenAgentsWeb.InferenceProxyControllerTest do

139 141
    assert selected["grant_model"] == Models.default_id()
140 142
    assert selected["selected_model"] == Models.default_id()
141 143
    assert selected["provider_model"] == default.provider_model
144
    assert selected["pricing_id"] == Pricing.pricing_id_for(default.id)
145
    assert selected["pricing_basis"] == Pricing.basis(default.id)
142 146
    assert selected["input_message_count"] == 1
143 147
    assert selected["tool_definition_count"] == 0
144 148
    assert selected["tool_output_count"] == 0
test/openagents_web/controllers/thread_controller_test.exs modified +15 -8

@@ -11,7 +11,7 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

11 11
  alias OpenAgents.Inference
12 12
  alias OpenAgents.Inference.Credit
13 13
  alias OpenAgents.Inference.Grant
14
  alias OpenAgents.Inference.Models
14
  alias OpenAgents.Inference.{Models, Pricing}
15 15
  alias OpenAgents.Repo
16 16
  alias OpenAgents.Threads
17 17
  alias OpenAgents.UnpricedLane

@@ -291,12 +291,16 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

291 291
292 292
      opened =
293 293
        authenticated
294
        |> post(~p"/api/v1/threads", %{"objective" => "Spend it all."})
294
        |> post(~p"/api/v1/threads", %{
295
          "objective" => "Spend it all.",
296
          "model" => "gemini-3.7-flash"
297
        })
295 298
        |> json_response(201)
296 299
297 300
      grant = Repo.get_by!(Grant, thread_id: opened["thread"]["id"])
298 301
      allowance = Credit.allowance(grant.owner_visitor_id)
299
      rate = Models.default().pricing.output_per_million_tokens
302
      {:ok, paid_model} = Models.fetch("gemini-3.7-flash")
303
      rate = Pricing.effective_pricing(paid_model).output_per_million_tokens
300 304
301 305
      {:ok, _metered} =
302 306
        Inference.record_usage(grant, %{

@@ -1553,17 +1557,20 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

1553 1557
1554 1558
      # A million input tokens costs exactly the default lane's per-million
1555 1559
      # rate, whatever the catalog's head happens to be.
1556
      rate = Models.default().pricing.input_per_million_tokens
1560
      pricing = Pricing.effective_pricing(Models.default())
1561
      rate = pricing.input_per_million_tokens
1557 1562
1558 1563
      body = authenticated |> get(~p"/api/v1/threads/#{id}") |> json_response(200)
1559 1564
1560 1565
      assert body["thread"]["spend"]["cost"]["microusd"] == rate
1561
      assert body["thread"]["spend"]["cost"]["basis"] == "provisional"
1566
      assert body["thread"]["spend"]["cost"]["basis"] == Pricing.basis_of(pricing)
1562 1567
      assert body["thread"]["spend"]["cost"]["unpriced_models"] == []
1563 1568
1564
      # Priced is not the same as billable: these are placeholder rates.
1565
      assert body["grant"]["pricing"]["basis"] == "provisional"
1566
      assert body["grant"]["pricing"]["billable"] == false
1569
      assert body["grant"]["pricing"]["basis"] == Pricing.basis_of(pricing)
1570
1571
      assert body["grant"]["pricing"]["billable"] ==
1572
               (Pricing.basis_of(pricing) == "declared")
1573
1567 1574
      assert body["grant"]["spent"]["cost_microusd"] == rate
1568 1575
    end
1569 1576
  end
test/openagents_web/home_controller_test.exs modified +4 -2

@@ -24,6 +24,7 @@ defmodule OpenAgentsWeb.HomeControllerTest do

24 24
    refute html =~ "One continuing conversation"
25 25
    refute html =~ ~s(href="/chat")
26 26
    refute html =~ "Account menu"
27
    refute html =~ ~s(href="/changelog")
27 28
  end
28 29
29 30
  test "an authenticated account sees the dashboard, not the pitch", %{conn: conn} do

@@ -31,10 +32,11 @@ defmodule OpenAgentsWeb.HomeControllerTest do

31 32
    html = html_response(get(conn, ~p"/"), 200)
32 33
33 34
    # Someone who has signed in has already been sold. The same route shows the
34
    # state of the work instead: open issues, projects, and what shipped.
35
    # state of the work instead: open issues and projects.
35 36
    refute html =~ "Your all-in-one coding agent."
36 37
    assert html =~ "Open issues"
37
    assert html =~ "Latest from the changelog"
38
    refute html =~ "Latest from the changelog"
39
    refute html =~ ~s(href="/changelog")
38 40
    assert html =~ ~s(class="dashboard")
39 41
  end
40 42
test/openagents_web/live/changelog_live_test.exs modified +9 -40

@@ -1,11 +1,6 @@

1 1
defmodule OpenAgentsWeb.ChangelogLiveTest do
2 2
  @moduledoc """
3
  The public changelog page and its API twin (#138, TRANSPARENCY-001):
4
  opens without a session, shows the human layer with the receipt detail
5
  expandable underneath, keeps the anonymous command bar free of account
6
  controls, stays content-free of node internals, and serves the same
7
  timeline as schema-versioned JSON at /api/changelog — 404 for a dark
8
  repo.
3
  The documentation changelog and the retained changelog API.
9 4
  """
10 5
11 6
  use OpenAgentsWeb.ConnCase, async: false

@@ -31,44 +26,18 @@ defmodule OpenAgentsWeb.ChangelogLiveTest do

31 26
    %{entry: entry}
32 27
  end
33 28
34
  test "renders the entry's summary and category for a visitor with no session", %{conn: conn} do
35
    {:ok, _view, html} = live(conn, ~p"/changelog")
29
  test "GET /changelog redirects to the documentation changelog", %{conn: conn} do
30
    conn = get(conn, ~p"/changelog")
36 31
37
    assert html =~ "Changelog"
38
    assert html =~ "Moved the mic button test entry"
39
    assert html =~ ~s(id="changelog-filter-ui")
32
    assert redirected_to(conn, 302) == ~p"/docs/changelog"
40 33
  end
41 34
42
  test "the detail expansion carries the sha and a link to the commit page", %{conn: conn} do
43
    {:ok, _view, html} = live(conn, ~p"/changelog")
35
  test "the documentation changelog presents Coder 0.1", %{conn: conn} do
36
    {:ok, _view, html} = live(conn, ~p"/docs/changelog")
44 37
45
    assert html =~ "abcdef1"
46
    assert html =~ "/OpenAgentsInc/openagents.com/commit/abcdef1"
47
  end
48
49
  test "carries the shared command bar without account controls for a visitor", %{conn: conn} do
50
    {:ok, _view, html} = live(conn, ~p"/changelog")
51
52
    # The shell supplies the one command bar; the page no longer builds a second.
53
    refute html =~ ~s(class="command-bar")
54
    refute html =~ ~s(id="account-bar-trigger")
55
    refute html =~ ~s(id="return-to-conversation")
56
  end
57
58
  test "carries the shell sidebar when logged in", %{conn: conn} do
59
    conn = log_in_chatting_user(conn, "changelog-header-browser")
60
61
    {:ok, _view, html} = live(conn, ~p"/changelog")
62
63
    # Chat is a sidebar row, so the page carries no chip back to it.
64
    refute html =~ ~s(id="return-to-conversation")
65
    assert html =~ ~s(href="/sarah")
66
  end
67
68
  test "publishes no node internals", %{conn: conn} do
69
    {:ok, _view, html} = live(conn, ~p"/changelog")
70
71
    refute html =~ to_string(node())
38
    assert html =~ "Coder 0.1"
39
    assert html =~ "Choose how inference runs"
40
    assert html =~ "Delegate work"
72 41
  end
73 42
74 43
  test "GET /api/changelog returns the schema-versioned projection", %{conn: conn} do
test/openagents_web/live/home_dashboard_live_test.exs modified +2 -72

@@ -6,8 +6,8 @@ defmodule OpenAgentsWeb.HomeDashboardLiveTest do

6 6
  across every repository they can read rather than one repository picked for
7 7
  them, that its counts agree with `/issues` and `/projects` because they are
8 8
  read the same way, that a repository the viewer cannot read is absent from
9
  both the rows and the numbers, that each kind of emptiness explains itself,
10
  and that a changelog row says what it is instead of rendering a bare time.
9
  both the rows and the numbers, and that each kind of emptiness explains
10
  itself.
11 11
  """
12 12
13 13
  use OpenAgentsWeb.ConnCase, async: false

@@ -15,8 +15,6 @@ defmodule OpenAgentsWeb.HomeDashboardLiveTest do

15 15
  import Ecto.Query
16 16
  import Phoenix.LiveViewTest
17 17
18
  alias OpenAgents.Changelog
19
  alias OpenAgents.Forge.DeployReceipt
20 18
  alias OpenAgents.Issues
21 19
  alias OpenAgents.Projects
22 20
  alias OpenAgents.Repo

@@ -25,9 +23,6 @@ defmodule OpenAgentsWeb.HomeDashboardLiveTest do

25 23
  alias OpenAgents.Repositories.Repository
26 24
27 25
  setup %{conn: conn} do
28
    :persistent_term.erase({OpenAgents.Changelog, :cache})
29
    on_exit(fn -> :persistent_term.erase({OpenAgents.Changelog, :cache}) end)
30
31 26
    owner = github_user("home-dashboard-owner")
32 27
33 28
    # Private with a membership, so the same seeding proves both halves: the

@@ -160,55 +155,6 @@ defmodule OpenAgentsWeb.HomeDashboardLiveTest do

160 155
    refute html =~ "No open issues in the repositories you can read."
161 156
  end
162 157
163
  describe "the changelog rail" do
164
    test "renders each row's summary beside its time", context do
165
      {:ok, _entry} =
166
        Changelog.record(%{
167
          repo: "openagents.com",
168
          sha: String.pad_trailing("d00dfeed", 40, "0"),
169
          summary: "Moved the counts beside the issues they count",
170
          category: "ui",
171
          source: "operator",
172
          entry_at: DateTime.utc_now(),
173
          visibility: "l2"
174
        })
175
176
      {:ok, view, _html} = live(context.conn, ~p"/")
177
178
      assert has_element?(
179
               view,
180
               ".changelog-rail__summary",
181
               "Moved the counts beside the issues they count"
182
             )
183
184
      assert every_row_says_something?(view)
185
    end
186
187
    test "states what a receipted deploy nobody wrote a note for is", context do
188
      insert_deploy!(String.pad_trailing("beefcafe", 40, "0"))
189
190
      {:ok, view, _html} = live(context.conn, ~p"/")
191
192
      # The ledger's agent-layer rows carry no authored note. The rail used to
193
      # render their time against an empty line, which is the defect.
194
      assert has_element?(view, ".changelog-rail__summary", "Receipted deploy of beefcafe0000")
195
      assert every_row_says_something?(view)
196
    end
197
  end
198
199
  defp every_row_says_something?(view) do
200
    document = view |> render() |> LazyHTML.from_fragment()
201
202
    summaries =
203
      document
204
      |> LazyHTML.query(".changelog-rail__summary")
205
      |> Enum.map(&(&1 |> LazyHTML.text() |> String.trim()))
206
207
    times = document |> LazyHTML.query(".changelog-rail__when") |> Enum.count()
208
209
    length(summaries) == times and summaries != [] and Enum.all?(summaries, &(&1 != ""))
210
  end
211
212 158
  defp integer_at(view, selector) do
213 159
    view
214 160
    |> render()

@@ -237,22 +183,6 @@ defmodule OpenAgentsWeb.HomeDashboardLiveTest do

237 183
    project
238 184
  end
239 185
240
  defp insert_deploy!(sha) do
241
    {:ok, deploy} =
242
      %DeployReceipt{}
243
      |> DeployReceipt.changeset(%{
244
        repo: "openagents.com",
245
        sha: sha,
246
        target_id: Ecto.UUID.generate(),
247
        result: "live",
248
        modules: ["Elixir.OpenAgents.Something"],
249
        nodes: ["node-a"]
250
      })
251
      |> Repo.insert()
252
253
    deploy
254
  end
255
256 186
  defp ready_repository!(owner, name, visibility) do
257 187
    {:ok, repository, :created} =
258 188
      Repositories.create_user_repository(
test/openagents_web/live/home_live_updates_test.exs modified +1 -53

@@ -24,7 +24,6 @@ defmodule OpenAgentsWeb.HomeLiveUpdatesTest do

24 24
  import Ecto.Query
25 25
  import Phoenix.LiveViewTest
26 26
27
  alias OpenAgents.Changelog
28 27
  alias OpenAgents.Forum
29 28
  alias OpenAgents.Issues
30 29
  alias OpenAgents.Projects

@@ -39,9 +38,6 @@ defmodule OpenAgentsWeb.HomeLiveUpdatesTest do

39 38
  }
40 39
41 40
  setup %{conn: conn} do
42
    :persistent_term.erase({OpenAgents.Changelog, :cache})
43
    on_exit(fn -> :persistent_term.erase({OpenAgents.Changelog, :cache}) end)
44
45 41
    owner = github_user("home-live-owner")
46 42
    repository = ready_repository!(owner, "live-repository", "private")
47 43

@@ -195,41 +191,6 @@ defmodule OpenAgentsWeb.HomeLiveUpdatesTest do

195 191
    end
196 192
  end
197 193
198
  describe "the changelog rail" do
199
    test "an appended entry joins the rail", context do
200
      {:ok, view, _html} = live(context.conn, ~p"/")
201
202
      refute render(view) =~ "Moved the counts beside the issues they count"
203
204
      {:ok, _entry} = record_entry!("d00dfeed", "Moved the counts beside the issues they count")
205
206
      assert has_element?(
207
               view,
208
               ".changelog-rail__summary",
209
               "Moved the counts beside the issues they count"
210
             )
211
    end
212
213
    test "a client that reconnects reads the current ledger, not the cached one", context do
214
      # The rail is served from a five-second cache, which is what a remounting
215
      # client reads. Left in place it would hand a page that dropped and came
216
      # back the ledger as it was.
217
      {:ok, _view, _html} = live(context.conn, ~p"/")
218
219
      {:ok, _entry} = record_entry!("feedbeef", "Landed while the client was away")
220
221
      {:ok, reconnected, html} = live(context.conn, ~p"/")
222
223
      assert html =~ "Landed while the client was away"
224
225
      assert has_element?(
226
               reconnected,
227
               ".changelog-rail__summary",
228
               "Landed while the client was away"
229
             )
230
    end
231
  end
232
233 194
  describe "authorization" do
234 195
    test "a write in a repository the viewer cannot read moves nothing", context do
235 196
      stranger = github_user("home-live-stranger")

@@ -346,10 +307,9 @@ defmodule OpenAgentsWeb.HomeLiveUpdatesTest do

346 307
        end)
347 308
348 309
      # Which panels are stale is remembered beside the timer that fires them,
349
      # so an issue burst never re-reads the forum, the ledger, or the projects.
310
      # so an issue burst never re-reads the forum or the projects.
350 311
      refute Enum.any?(sql, &String.contains?(&1, ~s(FROM "forum_posts")))
351 312
      refute Enum.any?(sql, &String.contains?(&1, ~s(FROM "projects")))
352
      refute Enum.any?(sql, &String.contains?(&1, ~s(FROM "changelog_entries")))
353 313
    end
354 314
  end
355 315

@@ -403,18 +363,6 @@ defmodule OpenAgentsWeb.HomeLiveUpdatesTest do

403 363
    |> String.to_integer()
404 364
  end
405 365
406
  defp record_entry!(prefix, summary) do
407
    Changelog.record(%{
408
      repo: "openagents.com",
409
      sha: String.pad_trailing(prefix, 40, "0"),
410
      summary: summary,
411
      category: "ui",
412
      source: "operator",
413
      entry_at: DateTime.utc_now(),
414
      visibility: "l2"
415
    })
416
  end
417
418 366
  defp board!(slug, title, opts \\ []) do
419 367
    {:ok, board} =
420 368
      %Forum.Forum{}
test/openagents_web/live/network_status_live_test.exs modified +4 -1

@@ -11,7 +11,10 @@ defmodule OpenAgentsWeb.NetworkStatusLiveTest do

11 11
    assert html =~ "node 1"
12 12
    assert has_element?(view, ".status-metric__label", "computers connected")
13 13
    assert has_element?(view, "#status-scvs")
14
    assert has_element?(view, "#status-no-scvs")
14
15
    assert has_element?(view, "#status-no-scvs") or
16
             has_element?(view, "#public-scv-streams")
17
15 18
    # Content-free: the serving node's internal name never reaches the page.
16 19
    refute html =~ to_string(node())
17 20
  end
test/openagents_web/live/thread_show_live_test.exs modified +3 -2

@@ -144,7 +144,7 @@ defmodule OpenAgentsWeb.ThreadShowLiveTest do

144 144
      assert view |> element("#thread-budget-cost-note") |> render() =~ "unknown rather than zero"
145 145
    end
146 146
147
    test "a priced lane shows the figure and says the rates are provisional", %{conn: conn} do
147
    test "a declared priced lane shows the billable figure", %{conn: conn} do
148 148
      owner = github_user("thread-show-priced")
149 149
150 150
      {:ok, thread} = Threads.open(owner, "Run a lane with rates")

@@ -160,7 +160,8 @@ defmodule OpenAgentsWeb.ThreadShowLiveTest do

160 160
      assert view |> element("#thread-budget-cost") |> render() =~
161 161
               "$#{:erlang.float_to_binary(dollars, decimals: 2)}"
162 162
163
      assert view |> element("#thread-budget-cost-note") |> render() =~ "not a bill"
163
      assert has_element?(view, ~s(#thread-budget-cost[data-basis="declared"]))
164
      refute has_element?(view, "#thread-budget-cost-note")
164 165
    end
165 166
166 167
    test "an unbounded ceiling reads as unbounded rather than blank", %{conn: conn} do
test/openagents_web/transparency_surface_test.exs modified +3 -4

@@ -3,7 +3,7 @@ defmodule OpenAgentsWeb.TransparencySurfaceTest do

3 3
  The executable enumeration behind TRANSPARENCY-001.
4 4
5 5
  TRANSPARENCY-001 states bounds that hold "at every level" and then lists the
6
  surfaces it is about by hand: `/changelog`, `/api/changelog`, and three forge
6
  surfaces it is about by hand: `/api/changelog` and three forge
7 7
  paths. `OpenAgents.Forge.VisibilityTest` proves the dial answers correctly and
8 8
  `OpenAgents.Forge.BrowseTest` proves the reads are bounded; neither can fail
9 9
  for a public surface nobody added to the list. When this file was written the

@@ -53,10 +53,9 @@ defmodule OpenAgentsWeb.TransparencySurfaceTest do

53 53
    {"get", "/:owner/:repo/pulls"} => :repository_readability
54 54
  }
55 55
56
  # The two receipt-chain surfaces, which publish the changelog projection
57
  # rather than repository content.
56
  # The receipt-chain surface publishes the changelog projection rather than
57
  # repository content.
58 58
  @changelog_surfaces %{
59
    {"get", "/changelog"} => OpenAgentsWeb.ChangelogLive,
60 59
    {"get", "/api/changelog"} => OpenAgentsWeb.ChangelogController
61 60
  }
62 61

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