Harden the thread events API for the transcript writer

d0a44b2f0b53 · AtlantisPleb · · parent 28aece52cfe1

Harden the thread events API for the transcript writer

Three contract improvements from issue #208, found while building the CLI
transcript writer:

- POST /api/v3/threads/{id}/events now returns the created event in the
  201 body ("event": id, schema, event_type, payload, emitted_at,
  inserted_at) beside the thread view, so a writer learns the cursor it
  just wrote. The CLI writer ignores the 2xx body, so the reshape breaks
  nothing.
- Event validation refusals carry the stable code "event_invalid" beside
  the field errors, symmetric with "thread_terminal", so a client tells a
  drop-only refusal from a retry-safe one without parsing prose.
- The same route accepts {"events": [...]}: an all-or-nothing batch in
  one transaction, order preserved, created events returned in order,
  capped at Threads.maximum_event_batch/0 (100) with its own code
  "event_batch_too_large". The terminal refusal covers the whole batch,
  and each committed event is broadcast in order after commit, exactly as
  a single append is.

Threads.record_event/3 keeps its contract and delegates to the new
Threads.record_events/2; a batch that rolls back broadcasts nothing.

Closes #208.

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>
Closes
#208

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 303 · 2026-08-24T20:18:39.150497Z

Changed files

  • modified lib/openagents/threads.ex
  • modified lib/openagents_web/api_error.ex
  • modified lib/openagents_web/controllers/thread_controller.ex
  • modified test/openagents/threads/event_broadcast_test.exs
  • modified test/openagents/threads/grant_token_reach_test.exs
  • modified test/openagents_web/controllers/thread_controller_test.exs

Diff

6 files changed, +415 -24

lib/openagents/threads.ex modified +59 -6

@@ -241,15 +241,46 @@ defmodule OpenAgents.Threads do

241 241
          {:ok, Thread.t()} | {:error, :thread_terminal | Ecto.Changeset.t()}
242 242
  def record_event(%Thread{} = thread, event_type, payload)
243 243
      when is_binary(event_type) and is_map(payload) do
244
    case record_events(thread, [%{event_type: event_type, payload: payload}]) do
245
      {:ok, updated, _events} -> {:ok, updated}
246
      {:error, {_index, %Ecto.Changeset{} = changeset}} -> {:error, changeset}
247
      {:error, reason} -> {:error, reason}
248
    end
249
  end
250
251
  @doc """
252
  Append a batch of events to a thread's transcript, all or nothing.
253
254
  One transaction, insertion order preserved: either every entry lands, in the
255
  order given, or nothing does. A transcript with a hole in the middle
256
  describes a session that never happened, so one invalid entry rolls the whole
257
  batch back and the refusal names its position as `{index, changeset}`.
258
259
  The terminal refusal covers the whole batch for the same reason
260
  `record_event/3` refuses at all, and each committed event is broadcast as
261
  `{:thread_event, event}` in order after the transaction, exactly as a single
262
  append is, so a subscriber cannot tell a batch from the same events posted
263
  one at a time.
264
265
  The batch's size is the caller's to bound (`maximum_event_batch/0` is what
266
  the public route enforces); this function bounds only its shape.
267
  """
268
  @spec record_events(Thread.t(), [%{event_type: String.t(), payload: map()}]) ::
269
          {:ok, Thread.t(), [Event.t()]}
270
          | {:error,
271
             :thread_terminal | Ecto.Changeset.t() | {non_neg_integer(), Ecto.Changeset.t()}}
272
  def record_events(%Thread{} = thread, entries) when is_list(entries) and entries != [] do
244 273
    now = DateTime.utc_now()
245 274
246 275
    Repo.transaction(fn ->
247 276
      case locked(thread.id) do
248 277
        %Thread{status: "open"} = current ->
249
          with {:ok, event} <- insert_event(current, event_type, payload, now),
278
          with {:ok, events} <- insert_events(current, entries, now),
250 279
               {:ok, updated} <-
251
                 current |> Thread.event_count_changeset(current.event_count + 1) |> Repo.update() do
252
            {updated, event}
280
                 current
281
                 |> Thread.event_count_changeset(current.event_count + length(events))
282
                 |> Repo.update() do
283
            {updated, events}
253 284
          else
254 285
            {:error, reason} -> Repo.rollback(reason)
255 286
          end

@@ -259,15 +290,37 @@ defmodule OpenAgents.Threads do

259 290
      end
260 291
    end)
261 292
    |> case do
262
      {:ok, {updated, event}} ->
263
        Phoenix.PubSub.broadcast(OpenAgents.PubSub, topic(updated.id), {:thread_event, event})
264
        {:ok, updated}
293
      {:ok, {updated, events}} ->
294
        for event <- events do
295
          Phoenix.PubSub.broadcast(OpenAgents.PubSub, topic(updated.id), {:thread_event, event})
296
        end
297
298
        {:ok, updated, events}
265 299
266 300
      {:error, reason} ->
267 301
        {:error, reason}
268 302
    end
269 303
  end
270 304
305
  @doc "How many events one batch append may carry."
306
  @spec maximum_event_batch() :: pos_integer()
307
  def maximum_event_batch, do: setting(:maximum_thread_event_batch, 100)
308
309
  defp insert_events(thread, entries, now) do
310
    entries
311
    |> Enum.with_index()
312
    |> Enum.reduce_while({:ok, []}, fn {entry, index}, {:ok, inserted} ->
313
      case insert_event(thread, entry.event_type, entry.payload, now) do
314
        {:ok, event} -> {:cont, {:ok, [event | inserted]}}
315
        {:error, changeset} -> {:halt, {:error, {index, changeset}}}
316
      end
317
    end)
318
    |> case do
319
      {:ok, inserted} -> {:ok, Enum.reverse(inserted)}
320
      {:error, reason} -> {:error, reason}
321
    end
322
  end
323
271 324
  @doc """
272 325
  Subscribe to a thread's transcript appends.
273 326
lib/openagents_web/api_error.ex modified +19 -1

@@ -66,6 +66,12 @@ defmodule OpenAgentsWeb.ApiError do

66 66
    # and its own code — never a silent substitution.
67 67
    "model_unavailable" => {503, "The model's provider is not configured on this deployment"},
68 68
    "thread_terminal" => {422, "This thread is terminal and its transcript is closed"},
69
    # A transcript writer meets two refusals that cannot change: a closed
70
    # thread, and an event the server has called invalid. `thread_terminal`
71
    # already carries a code; these give the other refusals of the append route
72
    # the same property, so a client drops or splits without parsing prose.
73
    "event_invalid" => {422, "The event could not be recorded"},
74
    "event_batch_too_large" => {422, "The batch carries more events than the maximum"},
69 75
    # Spending the account's inference credit is not a rate limit: no amount of
70 76
    # waiting or revoking makes the same call succeed, so it is the payment
71 77
    # status and its own code.

@@ -144,7 +150,19 @@ defmodule OpenAgentsWeb.ApiError do

144 150
  """
145 151
  @spec changeset(Plug.Conn.t(), Changeset.t(), keyword()) :: Plug.Conn.t()
146 152
  def changeset(conn, %Changeset{} = changeset, opts \\ []) do
147
    validation_failed(conn, Changeset.traverse_errors(changeset, &translate/1), opts)
153
    validation_failed(conn, changeset_errors(changeset), opts)
154
  end
155
156
  @doc """
157
  A changeset's errors as the envelope's field-to-messages map.
158
159
  For a route that refuses a changeset under its own code rather than the
160
  generic `validation_failed`: translate here, then pass the map to `refuse/3`
161
  as `:errors`.
162
  """
163
  @spec changeset_errors(Changeset.t()) :: map()
164
  def changeset_errors(%Changeset{} = changeset) do
165
    Changeset.traverse_errors(changeset, &translate/1)
148 166
  end
149 167
150 168
  @doc """
lib/openagents_web/controllers/thread_controller.ex modified +141 -17

@@ -88,21 +88,47 @@ defmodule OpenAgentsWeb.ThreadController do

88 88
  end
89 89
90 90
  @doc """
91
  Append one event to a thread's transcript.
91
  Append to a thread's transcript: one event, or a batch of them.
92 92
93 93
  Append-only and bounded: the payload is capped by the database, and a
94 94
  terminal thread refuses, because a transcript that keeps growing after the
95 95
  report was written is not the transcript the report describes.
96
97
  One route serves both shapes — `{"event_type": ..., "payload": ...}` appends
98
  one event, `{"events": [...]}` appends a batch — because there is one door to
99
  a transcript and the batch is the same act performed fewer round trips at a
100
  time. A batch lands all-or-nothing in one transaction, in order, capped at
101
  `OpenAgents.Threads.maximum_event_batch/0`, and the created events come back
102
  in order so a client learns every id it just wrote.
103
104
  A refused event carries the stable code `event_invalid` beside the field
105
  errors, symmetric with `thread_terminal`, so a client tells a drop-only
106
  refusal from a retry-safe one without parsing prose.
96 107
  """
97 108
  def record(conn, %{"thread_id" => thread_id} = params) do
98 109
    with_thread(conn, thread_id, fn thread ->
99
      case event_parameters(params) do
100
        {:ok, event_type, payload} -> append(conn, thread, event_type, payload)
101
        {:refused, field, message} -> ApiError.validation_failed(conn, %{field => [message]})
110
      case Map.fetch(params, "events") do
111
        {:ok, events} -> record_batch(conn, thread, events)
112
        :error -> record_single(conn, thread, params)
102 113
      end
103 114
    end)
104 115
  end
105 116
117
  defp record_single(conn, thread, params) do
118
    case event_parameters(params) do
119
      {:ok, event_type, payload} -> append(conn, thread, event_type, payload)
120
      {:refused, field, message} -> event_invalid(conn, %{field => [message]})
121
    end
122
  end
123
124
  defp record_batch(conn, thread, events) do
125
    case batch_parameters(events) do
126
      {:ok, entries} -> append_batch(conn, thread, entries)
127
      {:refused, field, message} -> event_invalid(conn, %{field => [message]})
128
      {:oversized, count, cap} -> batch_too_large(conn, count, cap)
129
    end
130
  end
131
106 132
  def show(conn, %{"thread_id" => thread_id}) do
107 133
    with_thread(conn, thread_id, fn thread -> render_thread(conn, :ok, thread) end)
108 134
  end

@@ -158,29 +184,78 @@ defmodule OpenAgentsWeb.ThreadController do

158 184
    end)
159 185
  end
160 186
187
  # The created event is the point of the 201: its id is the cursor a client
188
  # continues from, and a writer that never learns it cannot dedup its own
189
  # append against a later read. The thread rides along for the count.
161 190
  defp append(conn, thread, event_type, payload) do
162
    case Threads.record_event(thread, event_type, payload) do
163
      {:ok, updated} ->
191
    case Threads.record_events(thread, [%{event_type: event_type, payload: payload}]) do
192
      {:ok, updated, [event]} ->
164 193
        conn
165 194
        |> put_extension_header()
166 195
        |> put_status(:created)
167
        |> json(%{"thread" => thread_view(updated)})
196
        |> json(%{"event" => event_view(event), "thread" => thread_view(updated)})
168 197
169 198
      {:error, :thread_terminal} ->
170
        sentence =
171
          "This thread is #{thread.status} and its transcript is closed. " <>
172
            "Open another thread to record more work."
199
        thread_terminal(conn, thread)
173 200
174
        ApiError.refuse(conn, "thread_terminal",
175
          message: sentence,
176
          errors: %{"thread" => [sentence]}
177
        )
201
      {:error, {_index, changeset}} ->
202
        event_invalid(conn, ApiError.changeset_errors(changeset))
178 203
179
      {:error, changeset} ->
180
        ApiError.changeset(conn, changeset)
204
      {:error, %Ecto.Changeset{} = changeset} ->
205
        event_invalid(conn, ApiError.changeset_errors(changeset))
206
    end
207
  end
208
209
  defp append_batch(conn, thread, entries) do
210
    case Threads.record_events(thread, entries) do
211
      {:ok, updated, events} ->
212
        conn
213
        |> put_extension_header()
214
        |> put_status(:created)
215
        |> json(%{"events" => Enum.map(events, &event_view/1), "thread" => thread_view(updated)})
216
217
      {:error, :thread_terminal} ->
218
        thread_terminal(conn, thread)
219
220
      {:error, {index, changeset}} ->
221
        errors =
222
          changeset
223
          |> ApiError.changeset_errors()
224
          |> Map.new(fn {field, messages} -> {"events[#{index}].#{field}", messages} end)
225
226
        event_invalid(conn, errors)
227
228
      {:error, %Ecto.Changeset{} = changeset} ->
229
        event_invalid(conn, ApiError.changeset_errors(changeset))
181 230
    end
182 231
  end
183 232
233
  defp thread_terminal(conn, thread) do
234
    sentence =
235
      "This thread is #{thread.status} and its transcript is closed. " <>
236
        "Open another thread to record more work."
237
238
    ApiError.refuse(conn, "thread_terminal",
239
      message: sentence,
240
      errors: %{"thread" => [sentence]}
241
    )
242
  end
243
244
  defp event_invalid(conn, errors) do
245
    ApiError.refuse(conn, "event_invalid", errors: errors)
246
  end
247
248
  defp batch_too_large(conn, count, cap) do
249
    sentence =
250
      "This batch carries #{count} events and the maximum is #{cap}. " <>
251
        "Split it and post the parts in order."
252
253
    ApiError.refuse(conn, "event_batch_too_large",
254
      message: sentence,
255
      errors: %{"events" => [sentence]}
256
    )
257
  end
258
184 259
  # ── admission ───────────────────────────────────────────────────────────
185 260
186 261
  defp open(conn, objective, options) do

@@ -298,6 +373,54 @@ defmodule OpenAgentsWeb.ThreadController do

298 373
    end
299 374
  end
300 375
376
  # The whole batch is parsed before anything is appended, so a refusal names
377
  # the entry by its position and leaves nothing behind. An empty batch is
378
  # refused rather than answered 201: a client that posted nothing and read
379
  # "created" would believe something landed.
380
  defp batch_parameters(events) when is_list(events) do
381
    cap = Threads.maximum_event_batch()
382
383
    cond do
384
      events == [] ->
385
        {:refused, "events", "A batch appends at least one event."}
386
387
      length(events) > cap ->
388
        {:oversized, length(events), cap}
389
390
      true ->
391
        events
392
        |> Enum.with_index()
393
        |> Enum.reduce_while({:ok, []}, fn {event, index}, {:ok, entries} ->
394
          case batch_entry(event, index) do
395
            {:ok, entry} -> {:cont, {:ok, [entry | entries]}}
396
            {:refused, _field, _message} = refusal -> {:halt, refusal}
397
          end
398
        end)
399
        |> case do
400
          {:ok, entries} -> {:ok, Enum.reverse(entries)}
401
          {:refused, _field, _message} = refusal -> refusal
402
        end
403
    end
404
  end
405
406
  defp batch_parameters(_events) do
407
    {:refused, "events", "The events key carries an array of events."}
408
  end
409
410
  defp batch_entry(event, index) when is_map(event) do
411
    case event_parameters(event) do
412
      {:ok, event_type, payload} ->
413
        {:ok, %{event_type: event_type, payload: payload}}
414
415
      {:refused, field, message} ->
416
        {:refused, "events[#{index}].#{field}", message}
417
    end
418
  end
419
420
  defp batch_entry(event, index) do
421
    {:refused, "events[#{index}]", "#{inspect(event)} is not an object."}
422
  end
423
301 424
  defp event_type(%{"event_type" => event_type}) when is_binary(event_type) do
302 425
    if String.trim(event_type) == "" do
303 426
      {:refused, "event_type", "The event type names what happened and cannot be blank."}

@@ -409,7 +532,8 @@ defmodule OpenAgentsWeb.ThreadController do

409 532
      "schema" => event.schema,
410 533
      "event_type" => event.event_type,
411 534
      "payload" => event.payload,
412
      "emitted_at" => stamp(event.emitted_at)
535
      "emitted_at" => stamp(event.emitted_at),
536
      "inserted_at" => stamp(event.inserted_at)
413 537
    }
414 538
  end
415 539
test/openagents/threads/event_broadcast_test.exs modified +42

@@ -30,6 +30,48 @@ defmodule OpenAgents.Threads.EventBroadcastTest do

30 30
    refute_receive {:thread_event, _event}
31 31
  end
32 32
33
  test "a committed batch broadcasts each event once, in order" do
34
    user = github_user("thread-broadcast-batch")
35
    {:ok, thread} = Threads.open(user, "Broadcast the batch")
36
37
    :ok = Threads.subscribe(thread)
38
39
    {:ok, _updated, events} =
40
      Threads.record_events(thread, [
41
        %{event_type: "turn.user", payload: %{"text" => "first"}},
42
        %{event_type: "tool.ran", payload: %{"tool" => "bash"}},
43
        %{event_type: "turn.assistant", payload: %{"text" => "third"}}
44
      ])
45
46
    # A subscriber cannot tell a batch from the same events posted one at a
47
    # time: one message per event, in the order they landed, none repeated.
48
    for event <- events do
49
      assert_receive {:thread_event, %Event{} = received}
50
      assert received.id == event.id
51
      assert received.event_type == event.event_type
52
    end
53
54
    refute_receive {:thread_event, _event}
55
  end
56
57
  test "a batch that rolls back broadcasts nothing" do
58
    user = github_user("thread-broadcast-rollback")
59
    {:ok, thread} = Threads.open(user, "Rolled back batches stay silent")
60
61
    :ok = Threads.subscribe(thread)
62
63
    assert {:error, {1, %Ecto.Changeset{}}} =
64
             Threads.record_events(thread, [
65
               %{event_type: "turn.user", payload: %{"text" => "valid"}},
66
               %{event_type: String.duplicate("x", 81), payload: %{}}
67
             ])
68
69
    # The first entry was inserted and then rolled back with the second, so a
70
    # subscriber must never have heard about it.
71
    refute_receive {:thread_event, _event}
72
    assert Threads.list_events(thread) |> Enum.map(& &1.event_type) == ["thread.opened"]
73
  end
74
33 75
  test "another thread's subscriber hears nothing" do
34 76
    user = github_user("thread-broadcast-scope")
35 77
    {:ok, mine} = Threads.open(user, "Mine")
test/openagents/threads/grant_token_reach_test.exs modified +2

@@ -62,6 +62,7 @@ defmodule OpenAgents.Threads.GrantTokenReachTest do

62 62
    {:list_events, 2} => :thread_struct,
63 63
    {:list_for_user, 1} => :scoped_by_owner,
64 64
    {:list_for_user, 2} => :scoped_by_owner,
65
    {:maximum_event_batch, 0} => :no_thread,
65 66
    {:maximum_open_per_account, 0} => :no_thread,
66 67
    {:mint_grant, 1} => :returns_plaintext_token,
67 68
    {:open, 2} => :scoped_by_owner,

@@ -71,6 +72,7 @@ defmodule OpenAgents.Threads.GrantTokenReachTest do

71 72
    {:open_count, 1} => :scoped_by_owner,
72 73
    {:reap_expired, 1} => :scoped_by_owner,
73 74
    {:record_event, 3} => :thread_struct,
75
    {:record_events, 2} => :thread_struct,
74 76
    {:subscribe, 1} => :thread_struct
75 77
  }
76 78
test/openagents_web/controllers/thread_controller_test.exs modified +152

@@ -636,12 +636,36 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

636 636
      assert body["thread"]["event_count"] > 0
637 637
    end
638 638
639
    test "returns the created event, whose id is the cursor", %{authenticated: conn, id: id} do
640
      body =
641
        conn
642
        |> post(~p"/api/v3/threads/#{id}/events", %{
643
          "event_type" => "turn.user",
644
          "payload" => %{"text" => "echo me back"}
645
        })
646
        |> json_response(201)
647
648
      # A writer that never learns its event's id cannot continue from it or
649
      # dedup its own append against a later read, so the 201 carries the event
650
      # rather than only the thread it landed on.
651
      assert is_integer(body["event"]["id"])
652
      assert body["event"]["event_type"] == "turn.user"
653
      assert body["event"]["payload"] == %{"text" => "echo me back"}
654
      assert is_binary(body["event"]["inserted_at"])
655
656
      read = conn |> get(~p"/api/v3/threads/#{id}/events") |> json_response(200)
657
      assert List.last(read["events"])["id"] == body["event"]["id"]
658
    end
659
639 660
    test "refuses an event with no type", %{authenticated: conn, id: id} do
640 661
      body =
641 662
        conn
642 663
        |> post(~p"/api/v3/threads/#{id}/events", %{"payload" => %{"text" => "x"}})
643 664
        |> json_response(422)
644 665
666
      # The code is the machine's half of the refusal, symmetric with
667
      # `thread_terminal`: a client drops the event without parsing prose.
668
      assert body["code"] == "event_invalid"
645 669
      assert body["errors"]["event_type"] != nil
646 670
    end
647 671

@@ -658,6 +682,134 @@ defmodule OpenAgentsWeb.ThreadControllerTest do

658 682
      assert body["code"] == "thread_terminal"
659 683
    end
660 684
685
    test "appends a batch in order and returns the created events", %{
686
      authenticated: conn,
687
      id: id
688
    } do
689
      before = conn |> get(~p"/api/v3/threads/#{id}") |> json_response(200)
690
691
      body =
692
        conn
693
        |> post(~p"/api/v3/threads/#{id}/events", %{
694
          "events" => [
695
            %{"event_type" => "turn.user", "payload" => %{"text" => "first"}},
696
            %{"event_type" => "tool.ran", "payload" => %{"tool" => "bash"}},
697
            %{"event_type" => "turn.assistant", "payload" => %{"text" => "third"}}
698
          ]
699
        })
700
        |> json_response(201)
701
702
      # A tool-heavy turn no longer costs one round trip per event, and the
703
      # created events come back in the order they landed so the writer learns
704
      # every id it just wrote.
705
      assert Enum.map(body["events"], & &1["event_type"]) ==
706
               ["turn.user", "tool.ran", "turn.assistant"]
707
708
      ids = Enum.map(body["events"], & &1["id"])
709
      assert ids == Enum.sort(ids)
710
      assert Enum.all?(body["events"], &is_binary(&1["inserted_at"]))
711
      assert body["thread"]["event_count"] == before["thread"]["event_count"] + 3
712
713
      read = conn |> get(~p"/api/v3/threads/#{id}/events") |> json_response(200)
714
      assert Enum.take(read["events"], -3) |> Enum.map(& &1["id"]) == ids
715
    end
716
717
    test "a batch with one invalid event records nothing", %{authenticated: conn, id: id} do
718
      before = conn |> get(~p"/api/v3/threads/#{id}") |> json_response(200)
719
720
      # The second entry passes the route's parse — its type is non-blank — and
721
      # is refused by the database's 80-character ceiling, so the refusal
722
      # proves the transaction rolled the first entry back with it.
723
      body =
724
        conn
725
        |> post(~p"/api/v3/threads/#{id}/events", %{
726
          "events" => [
727
            %{"event_type" => "turn.user", "payload" => %{"text" => "landed?"}},
728
            %{"event_type" => String.duplicate("x", 81), "payload" => %{}}
729
          ]
730
        })
731
        |> json_response(422)
732
733
      assert body["code"] == "event_invalid"
734
      assert body["errors"]["events[1].event_type"] != nil
735
736
      after_refusal = conn |> get(~p"/api/v3/threads/#{id}") |> json_response(200)
737
      assert after_refusal["thread"]["event_count"] == before["thread"]["event_count"]
738
    end
739
740
    test "a batch entry with no type is refused naming its position", %{
741
      authenticated: conn,
742
      id: id
743
    } do
744
      body =
745
        conn
746
        |> post(~p"/api/v3/threads/#{id}/events", %{
747
          "events" => [
748
            %{"event_type" => "turn.user"},
749
            %{"payload" => %{"text" => "no type"}}
750
          ]
751
        })
752
        |> json_response(422)
753
754
      assert body["code"] == "event_invalid"
755
      assert body["errors"]["events[1].event_type"] != nil
756
    end
757
758
    test "an empty batch is refused rather than answered created", %{
759
      authenticated: conn,
760
      id: id
761
    } do
762
      body =
763
        conn
764
        |> post(~p"/api/v3/threads/#{id}/events", %{"events" => []})
765
        |> json_response(422)
766
767
      assert body["code"] == "event_invalid"
768
      assert body["errors"]["events"] != nil
769
    end
770
771
    test "a batch over the cap is refused with its own code", %{authenticated: conn, id: id} do
772
      previous = Application.get_env(:openagents, :maximum_thread_event_batch)
773
      Application.put_env(:openagents, :maximum_thread_event_batch, 2)
774
775
      on_exit(fn ->
776
        Application.put_env(:openagents, :maximum_thread_event_batch, previous)
777
      end)
778
779
      body =
780
        conn
781
        |> post(~p"/api/v3/threads/#{id}/events", %{
782
          "events" =>
783
            for index <- 1..3 do
784
              %{"event_type" => "turn.user", "payload" => %{"index" => index}}
785
            end
786
        })
787
        |> json_response(422)
788
789
      # Over the cap is not an invalid event — every entry may be well formed —
790
      # so it carries its own code, and the sentence names the split.
791
      assert body["code"] == "event_batch_too_large"
792
      assert body["message"] =~ "2"
793
      assert [sentence] = body["errors"]["events"]
794
      assert sentence =~ "3 events"
795
    end
796
797
    test "refuses a batch to a revoked thread as one refusal", %{authenticated: conn, id: id} do
798
      conn |> delete(~p"/api/v3/threads/#{id}") |> json_response(200)
799
800
      body =
801
        conn
802
        |> post(~p"/api/v3/threads/#{id}/events", %{
803
          "events" => [
804
            %{"event_type" => "turn.user", "payload" => %{"text" => "late"}},
805
            %{"event_type" => "turn.assistant", "payload" => %{"text" => "later"}}
806
          ]
807
        })
808
        |> json_response(422)
809
810
      assert body["code"] == "thread_terminal"
811
    end
812
661 813
    test "does not read another account's transcript", %{authenticated: conn, id: id} do
662 814
      conn
663 815
      |> post(~p"/api/v3/threads/#{id}/events", %{"event_type" => "turn.user"})

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