Drive Ox Alpha from the /chat console

744522f79f0d · Devin AI · · parent 733b943616a2

Drive Ox Alpha from the /chat console

The `/chat` placeholder became the operator-only Ox Alpha console. It streams
Ox Alpha through the server-side OpenRouter adapter, and it requests
`stealth/ox-alpha` alone, so a failed turn reports a failure instead of
answering as another model.

An operator can stop a turn in flight: cancellation kills the provider task
and journals `response_cancelled` in the transaction that flips the run out of
`streaming`, so the durable run and the transcript agree. A failed turn keeps
its prompt in the composer and offers a retry where a retry can fix the
failure: a rate limit, an unavailable provider, an interrupted stream, or a
malformed provider event.

The console shows only evidence the provider reported: provider lane, response
latency, the OpenRouter request identifier, and separate input, output,
reasoning, and cached token counts. Token counts move to their own `usage`
column because redaction blanks every field whose name contains `token`. The
context meter needs a window OpenRouter does not report, so it appears only
where a deployment states one, and the composer shows no aggregate fleet
throughput yet.

Co-Authored-By: Christopher David <chris@openagents.com>
Co-Authored-By
Christopher David <chris@openagents.com>

Deploy story

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

Not deployed through the forge lane

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

Changed files

  • modified assets/css/app.css
  • modified config/config.exs
  • modified docs/runtime-configuration.md
  • modified lib/openagents/chat/account_run.ex
  • modified lib/openagents/chat/account_turns.ex
  • modified lib/openagents/chat/open_router.ex
  • modified lib/openagents/chat/open_router/responses_stream_decoder.ex
  • modified lib/openagents/runtime_supervisor.ex
  • renamed lib/openagents_web/live/chat_console_live.ex
  • modified lib/openagents_web/router.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260823050000_add_cancelled_status_to_account_chat_runs.exs
  • modified test/openagents/chat/account_turns_test.exs
  • modified test/openagents/chat/open_router_test.exs
  • added test/openagents_web/live/chat_console_test.exs
  • deleted test/openagents_web/live/chat_placeholder_test.exs

Diff

16 files changed, +1141 -511

assets/css/app.css modified +8 -8

@@ -723,7 +723,7 @@ body.docs-sidebar-open {

723 723
  padding: 40px 64px 96px;
724 724
}
725 725
726
.chat-placeholder-composer {
726
.chat-console-composer {
727 727
  position: relative;
728 728
  flex: none;
729 729
  z-index: 30;

@@ -732,17 +732,17 @@ body.docs-sidebar-open {

732 732
  padding: 16px max(16px, env(safe-area-inset-right)) max(16px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
733 733
}
734 734
735
.chat-placeholder-composer__form {
735
.chat-console-composer__form {
736 736
  width: 100%;
737 737
  max-width: 46rem;
738 738
  margin-inline: auto;
739 739
}
740 740
741
.chat-placeholder-composer__toolbar {
741
.chat-console-composer__toolbar {
742 742
  padding-bottom: 8px;
743 743
}
744 744
745
.chat-placeholder-composer__reasoning {
745
.chat-console-composer__reasoning {
746 746
  width: auto;
747 747
  min-height: 28px;
748 748
  margin: 0;

@@ -755,13 +755,13 @@ body.docs-sidebar-open {

755 755
  line-height: 1rem;
756 756
}
757 757
758
.chat-placeholder-composer__reasoning:hover:not(:disabled),
759
.chat-placeholder-composer__reasoning:focus-visible {
758
.chat-console-composer__reasoning:hover:not(:disabled),
759
.chat-console-composer__reasoning:focus-visible {
760 760
  border-color: var(--line-strong);
761 761
  color: var(--text-primary);
762 762
}
763 763
764
.chat-placeholder-composer__submit {
764
.chat-console-composer__submit {
765 765
  width: 32px;
766 766
  height: 32px;
767 767
  min-height: 32px;

@@ -773,7 +773,7 @@ body.docs-sidebar-open {

773 773
}
774 774
775 775
@media (hover: hover) {
776
  .chat-placeholder-composer__submit:hover:not(:disabled) {
776
  .chat-console-composer__submit:hover:not(:disabled) {
777 777
    border-color: var(--text-muted);
778 778
    background: var(--ink-hover);
779 779
    color: var(--text-primary);
config/config.exs modified +6

@@ -110,6 +110,12 @@ config :openagents,

110 110
    poll_interval_ms: 1_000,
111 111
    poll_attempts: 60
112 112
  ],
113
  # The provider does not report a context window, so the `/chat` console shows
114
  # a context meter only where a deployment states one.
115
  openrouter_context_window: nil,
116
  # An override for the `/chat` streaming function. Tests set it; nothing else
117
  # does, so the console reaches OpenRouter everywhere else.
118
  chat_console_streamer: nil,
113 119
  shadow_programs: [
114 120
    enabled: false,
115 121
    provider: OpenAgents.ShadowPrograms.OpenAI,
docs/runtime-configuration.md modified +1 -1

@@ -156,7 +156,7 @@ arguments, repository URLs, receipts, or checked-in environment files.

156 156
| GitHub | `GITHUB_TOKEN_ENCRYPTION_KEY_ID` | Bounded active-key identifier prefixed with `development-`, `test-`, `staging-`, or `production-` to match the runtime |
157 157
| GitHub | `GITHUB_TOKEN_DECRYPTION_KEYS_JSON` | Optional map of at most 16 same-environment prior keys used only during rewrap; omit the active ID |
158 158
| Providers | `OPENAI_API_KEY` | Environment-specific provider secret; required by the current text provider |
159
| Providers | `OPENROUTER_API_KEY` | Environment-specific server-only credential for the `/chat` OpenRouter adapter; it requests `stealth/ox-alpha` and uses `openrouter/free` as the fallback; empty disables the adapter |
159
| Providers | `OPENROUTER_API_KEY` | Environment-specific server-only credential for the `/chat` OpenRouter adapter; the `/chat` console requests `stealth/ox-alpha` only, so a turn fails rather than answering as another model; empty disables the adapter |
160 160
| Providers | `OPENAGENTS_INFERENCE_PROXY_URL` | HTTPS URL without credentials when computers are enabled; empty disables |
161 161
| Computers | `OPENAGENTS_MACHINE_TOKEN_TTL_SECONDS` | `300` through `2592000`; Gate 5 uses the 30-day maximum |
162 162
| Recording | `VOICE_RECORDING_ENCRYPTION_KEY` | Base64-encoded 32-byte key when recording is enabled; empty disables recording storage |
lib/openagents/chat/account_run.ex modified +7 -1

@@ -13,7 +13,10 @@ defmodule OpenAgents.Chat.AccountRun do

13 13
    field :user_content, :string
14 14
    field :assistant_content, :string
15 15
    field :completion, :map
16
    field :usage, :map
16 17
    field :error, :string
18
    field :error_code, :string
19
    field :latency_ms, :integer
17 20
    field :started_at, :utc_datetime_usec
18 21
    field :completed_at, :utc_datetime_usec
19 22
    timestamps()

@@ -27,7 +30,10 @@ defmodule OpenAgents.Chat.AccountRun do

27 30
      :user_content,
28 31
      :assistant_content,
29 32
      :completion,
33
      :usage,
30 34
      :error,
35
      :error_code,
36
      :latency_ms,
31 37
      :started_at,
32 38
      :completed_at
33 39
    ])

@@ -38,7 +44,7 @@ defmodule OpenAgents.Chat.AccountRun do

38 44
      :user_content,
39 45
      :started_at
40 46
    ])
41
    |> validate_inclusion(:status, ["streaming", "completed", "failed"])
47
    |> validate_inclusion(:status, ["streaming", "completed", "failed", "cancelled"])
42 48
    |> foreign_key_constraint(:conversation_id)
43 49
    |> unique_constraint(:conversation_id,
44 50
      name: :account_chat_runs_one_streaming_per_conversation
lib/openagents/chat/account_turns.ex modified +188 -7

@@ -8,6 +8,15 @@ defmodule OpenAgents.Chat.AccountTurns do

8 8
  alias OpenAgents.Repo
9 9
10 10
  @max_message_bytes 8_000
11
  @run_registry OpenAgents.Chat.RunRegistry
12
  @retryable_error_codes ~w(
13
    rate_limited
14
    service_unavailable
15
    stream_interrupted
16
    invalid_response
17
    provider_unavailable
18
    server_error
19
  )
11 20
12 21
  def submit(user, content, options \\ [])
13 22

@@ -34,6 +43,27 @@ defmodule OpenAgents.Chat.AccountTurns do

34 43
35 44
  def submit(%User{}, _content, _options), do: {:error, :invalid_message}
36 45
46
  @doc """
47
  Stops the streaming run for this account.
48
49
  The provider task holds the open stream, so cancellation stops that task and
50
  then journals the cancellation in the same transaction that flips the run out
51
  of `streaming`. A run that already reached a terminal state stays there.
52
  """
53
  def cancel(%User{} = user) do
54
    with %{id: conversation_id} <- Conversations.get_conversation_for_user(user),
55
         %AccountRun{} = run <- streaming_run(conversation_id) do
56
      stop_provider_task(run.id)
57
      cancel_run(run.id)
58
    else
59
      _no_active_turn -> {:error, :no_active_turn}
60
    end
61
  end
62
63
  @doc "Whether an operator can retry the turn that recorded this error code."
64
  def retryable_error_code?(code) when is_binary(code), do: code in @retryable_error_codes
65
  def retryable_error_code?(_code), do: false
66
37 67
  def list_events(%User{} = user) do
38 68
    case Conversations.get_conversation_for_user(user) do
39 69
      nil -> []

@@ -148,10 +178,64 @@ defmodule OpenAgents.Chat.AccountTurns do

148 178
      {:error, :turn_in_progress}
149 179
  end
150 180
181
  defp streaming_run(conversation_id) do
182
    Repo.one(
183
      from r in AccountRun,
184
        where: r.conversation_id == ^conversation_id and r.status == "streaming",
185
        order_by: [desc: r.inserted_at],
186
        limit: 1
187
    )
188
  end
189
190
  defp stop_provider_task(run_id) do
191
    case Registry.lookup(@run_registry, run_id) do
192
      [{pid, _value}] -> Process.exit(pid, :kill)
193
      [] -> :ok
194
    end
195
  end
196
197
  defp cancel_run(run_id) do
198
    now = DateTime.utc_now()
199
200
    result =
201
      Repo.transaction(fn ->
202
        run = Repo.one!(from r in AccountRun, where: r.id == ^run_id, lock: "FOR UPDATE")
203
204
        if run.status == "streaming" do
205
          append_event_locked!(run, "response_cancelled", %{}, now)
206
207
          run
208
          |> AccountRun.changeset(%{
209
            status: "cancelled",
210
            assistant_content: streamed_text(run.id),
211
            completed_at: now,
212
            latency_ms: DateTime.diff(now, run.started_at, :millisecond)
213
          })
214
          |> Repo.update!()
215
        else
216
          Repo.rollback(:no_active_turn)
217
        end
218
      end)
219
220
    case result do
221
      {:ok, run} -> {:ok, run_projection(run)}
222
      {:error, reason} -> {:error, reason}
223
    end
224
  end
225
226
  defp streamed_text(run_id) do
227
    from(e in AccountEvent,
228
      where: e.run_id == ^run_id and e.kind == "text_delta",
229
      order_by: [asc: e.sequence],
230
      select: e.payload
231
    )
232
    |> Repo.all()
233
    |> Enum.map_join("", fn payload -> payload["value"] || "" end)
234
  end
235
151 236
  defp start_provider(run, user, subscriber, streamer) do
152 237
    request = %{
153 238
      "model" => OpenRouter.default_model(),
154
      "models" => ["openrouter/free"],
155 239
      "reasoning" => run.reasoning_effort,
156 240
      "messages" =>
157 241
        provider_history(run.conversation_id, run.id) ++

@@ -159,6 +243,8 @@ defmodule OpenAgents.Chat.AccountTurns do

159 243
    }
160 244
161 245
    case Task.Supervisor.start_child(OpenAgents.ProviderTaskSupervisor, fn ->
246
           register_provider_task(run.id)
247
162 248
           result =
163 249
             try do
164 250
               streamer.(

@@ -188,25 +274,42 @@ defmodule OpenAgents.Chat.AccountTurns do

188 274
    end
189 275
  end
190 276
277
  # Cancellation needs the process that holds the open stream, and the run id is
278
  # the only handle the browser has.
279
  defp register_provider_task(run_id) do
280
    case Registry.register(@run_registry, run_id, nil) do
281
      {:ok, _owner} -> :ok
282
      {:error, {:already_registered, _pid}} -> :ok
283
    end
284
  rescue
285
    ArgumentError -> :ok
286
  end
287
191 288
  defp persist_provider_event(run_id, {kind, payload}),
192 289
    do: append_event(run_id, Atom.to_string(kind), normalize_payload(payload))
193 290
194 291
  defp finish_run(run_id, {:ok, completion}) do
292
    # Token counts are read before redaction, which blanks every field whose
293
    # name contains `token`, and are stored beside the redacted completion.
294
    usage = usage_counts(completion["usage"])
195 295
    completion = OpenAgents.Tools.Redaction.redact(completion)
196 296
197 297
    terminal_update(run_id, "response_completed", completion, %{
198 298
      status: "completed",
199 299
      assistant_content: completion["assistant_content"] || "",
200
      completion: completion
300
      completion: completion,
301
      usage: usage
201 302
    })
202 303
  end
203 304
204 305
  defp finish_run(run_id, {:error, reason}) do
205 306
    error = public_error(reason)
307
    code = error_code(reason)
206 308
207
    terminal_update(run_id, "response_failed", %{"reason" => error}, %{
309
    terminal_update(run_id, "response_failed", %{"reason" => error, "code" => code}, %{
208 310
      status: "failed",
209
      error: error
311
      error: error,
312
      error_code: code
210 313
    })
211 314
  end
212 315

@@ -215,8 +318,19 @@ defmodule OpenAgents.Chat.AccountTurns do

215 318
216 319
    Repo.transaction(fn ->
217 320
      run = Repo.one!(from r in AccountRun, where: r.id == ^run_id, lock: "FOR UPDATE")
218
      append_event_locked!(run, kind, payload, now)
219
      run |> AccountRun.changeset(Map.put(attrs, :completed_at, now)) |> Repo.update!()
321
322
      if run.status == "streaming" do
323
        append_event_locked!(run, kind, payload, now)
324
325
        attrs =
326
          attrs
327
          |> Map.put(:completed_at, now)
328
          |> Map.put(:latency_ms, DateTime.diff(now, run.started_at, :millisecond))
329
330
        run |> AccountRun.changeset(attrs) |> Repo.update!()
331
      else
332
        run
333
      end
220 334
    end)
221 335
  end
222 336

@@ -310,6 +424,51 @@ defmodule OpenAgents.Chat.AccountTurns do

310 424
    if run.status == "streaming", do: [user], else: [user, assistant_message(run)]
311 425
  end
312 426
427
  # Provider-reported counts only. OpenRouter names them differently across its
428
  # two APIs, and a field the provider left out stays `nil` instead of a guess.
429
  defp usage_counts(usage) when is_map(usage) do
430
    counts = %{
431
      "input" => token_count(usage["input_tokens"] || usage["prompt_tokens"]),
432
      "output" => token_count(usage["output_tokens"] || usage["completion_tokens"]),
433
      "total" => token_count(usage["total_tokens"]),
434
      "reasoning" =>
435
        token_count(
436
          detail(usage, "output_tokens_details", "reasoning_tokens") ||
437
            detail(usage, "completion_tokens_details", "reasoning_tokens")
438
        ),
439
      "cached" =>
440
        token_count(
441
          detail(usage, "input_tokens_details", "cached_tokens") ||
442
            detail(usage, "prompt_tokens_details", "cached_tokens")
443
        )
444
    }
445
446
    if Enum.all?(Map.values(counts), &is_nil/1), do: nil, else: counts
447
  end
448
449
  defp usage_counts(_usage), do: nil
450
451
  defp usage_view(counts) when is_map(counts),
452
    do: %{
453
      input: counts["input"],
454
      output: counts["output"],
455
      total: counts["total"],
456
      reasoning: counts["reasoning"],
457
      cached: counts["cached"]
458
    }
459
460
  defp usage_view(_counts), do: nil
461
462
  defp detail(usage, key, field) do
463
    case usage[key] do
464
      details when is_map(details) -> details[field]
465
      _missing -> nil
466
    end
467
  end
468
469
  defp token_count(value) when is_integer(value) and value >= 0, do: value
470
  defp token_count(_value), do: nil
471
313 472
  defp assistant_message(run) do
314 473
    completion = run.completion
315 474
    reasoning = completion && completion["reasoning_summary"]

@@ -321,6 +480,13 @@ defmodule OpenAgents.Chat.AccountTurns do

321 480
      content: run.assistant_content || "",
322 481
      completion: completion,
323 482
      error: run.error,
483
      error_code: run.error_code,
484
      retryable?: retryable_error_code?(run.error_code),
485
      cancelled?: run.status == "cancelled",
486
      usage: usage_view(run.usage),
487
      provider_lane: completion && completion["provider"],
488
      request_id: completion && completion["request_id"],
489
      latency_ms: run.latency_ms,
324 490
      history?: run.status == "completed",
325 491
      provider_message_id: completion && completion["assistant_message_id"],
326 492
      provider_status: if(completion && completion["assistant_message_id"], do: "completed"),

@@ -420,10 +586,24 @@ defmodule OpenAgents.Chat.AccountTurns do

420 586
421 587
  defp normalize_payload(payload), do: %{"value" => OpenAgents.Tools.Redaction.redact(payload)}
422 588
  defp public_error(:missing_api_key), do: "OpenRouter is not configured for this environment."
423
  defp public_error(:rate_limited), do: "OpenRouter is rate-limited. Try again later."
589
  defp public_error(:rate_limited), do: "OpenRouter is rate-limited. Try again."
590
591
  defp public_error(:service_unavailable),
592
    do: "OpenRouter is unavailable right now. Try again."
593
594
  defp public_error(:stream_interrupted),
595
    do: "The response stream stopped before it finished. Try again."
596
597
  defp public_error(:invalid_response),
598
    do: "OpenRouter returned a response this console could not read. Try again."
599
424 600
  defp public_error(:provider_unavailable), do: "OpenRouter could not complete that message."
425 601
  defp public_error(:turn_start_failed), do: "The chat turn could not start."
426 602
  defp public_error(_reason), do: "OpenRouter could not complete that message."
603
604
  defp error_code(reason) when is_atom(reason), do: Atom.to_string(reason)
605
  defp error_code({:provider_error, code, _detail}) when is_binary(code), do: code
606
  defp error_code(_reason), do: "provider_error"
427 607
  defp maybe_put(map, _key, nil), do: map
428 608
  defp maybe_put(map, key, value), do: Map.put(map, key, value)
429 609
  defp notify(pid, message) when is_pid(pid), do: send(pid, message)

@@ -603,6 +783,7 @@ defmodule OpenAgents.Chat.AccountTurns do

603 783
      "id" => run.id,
604 784
      "status" => run.status,
605 785
      "reasoning_effort" => run.reasoning_effort,
786
      "latency_ms" => run.latency_ms,
606 787
      "started_at" => DateTime.to_iso8601(run.started_at)
607 788
    }
608 789
end
lib/openagents/chat/open_router.ex modified +58 -12

@@ -1,12 +1,13 @@

1 1
defmodule OpenAgents.Chat.OpenRouter do
2 2
  @moduledoc """
3
  Server-side OpenRouter Responses adapter for the `/chat` preview.
3
  Server-side OpenRouter Responses adapter for the `/chat` console.
4 4
5 5
  The adapter keeps the OpenRouter credential and HTTP transport on the server.
6
  It requests Ox Alpha first, with the Free Models Router as its configured
7
  model fallback. It uses chat completions only when a provider does not support
8
  Responses, and returns normalized failures without provider credentials or
9
  response bodies.
6
  It requests Ox Alpha and uses chat completions only when a provider does not
7
  support Responses. It returns normalized failures without provider
8
  credentials or response bodies, and it separates the failures an operator can
9
  retry — a rate limit, an unavailable provider, an interrupted stream, and a
10
  malformed stream — from the ones a retry cannot fix.
10 11
  """
11 12
12 13
  alias OpenAgents.Chat.OpenRouter.{ResponsesStreamDecoder, ToolRuntime}

@@ -14,6 +15,7 @@ defmodule OpenAgents.Chat.OpenRouter do

14 15
  @chat_completions_endpoint "https://openrouter.ai/api/v1/chat/completions"
15 16
  @responses_endpoint "https://openrouter.ai/api/v1/responses"
16 17
  @default_model "stealth/ox-alpha"
18
  @model_label "Ox Alpha"
17 19
  @maximum_tool_rounds 6
18 20
  @tool_instructions """
19 21
  Ground every repository claim in repository tool output. Never claim that a file or directory exists unless a tool result confirms it. Use list_repository_directory before guessing a path, and use the returned paths exactly. Do not retry the same failed repository, path, and ref combination. If a read fails, list its parent directory once or tell the user that the requested content is unavailable.

@@ -25,6 +27,8 @@ defmodule OpenAgents.Chat.OpenRouter do

25 27
          :missing_api_key
26 28
          | :rate_limited
27 29
          | :provider_unavailable
30
          | :service_unavailable
31
          | :stream_interrupted
28 32
          | :invalid_response
29 33
          | {:provider_error, String.t(), String.t() | nil}
30 34

@@ -48,6 +52,9 @@ defmodule OpenAgents.Chat.OpenRouter do

48 52
  @doc false
49 53
  def default_model, do: Application.get_env(:openagents, :openrouter_model, @default_model)
50 54
55
  @doc "The fixed label the console shows for the configured model."
56
  def model_label, do: @model_label
57
51 58
  @doc false
52 59
  def reasoning_effort(value) when value in @reasoning_efforts, do: value
53 60
  def reasoning_effort(_value), do: "high"

@@ -390,8 +397,7 @@ defmodule OpenAgents.Chat.OpenRouter do

390 397
391 398
  defp consume_stream(%Req.Response{body: %Req.Response.Async{} = body}, on_event) do
392 399
    body
393
    |> Enum.reduce_while({:ok, %{buffer: "", complete?: false, model: nil}}, fn chunk,
394
                                                                                {:ok, state} ->
400
    |> Enum.reduce_while({:ok, empty_chat_stream_state()}, fn chunk, {:ok, state} ->
395 401
      case consume_chunk(state, chunk, on_event) do
396 402
        {:ok, state} -> {:cont, {:ok, state}}
397 403
        {:error, reason} -> {:halt, {:error, reason}}

@@ -399,7 +405,7 @@ defmodule OpenAgents.Chat.OpenRouter do

399 405
    end)
400 406
    |> finish_stream()
401 407
  rescue
402
    _exception -> {:error, :provider_unavailable}
408
    _exception -> {:error, :stream_interrupted}
403 409
  end
404 410
405 411
  defp consume_stream(%Req.Response{}, _on_event), do: {:error, :invalid_response}

@@ -423,7 +429,7 @@ defmodule OpenAgents.Chat.OpenRouter do

423 429
    end)
424 430
    |> finish_responses_stream()
425 431
  rescue
426
    _exception -> {:error, :provider_unavailable}
432
    _exception -> {:error, :stream_interrupted}
427 433
  end
428 434
429 435
  defp consume_responses_stream(%Req.Response{}, _on_event, _model),

@@ -472,7 +478,7 @@ defmodule OpenAgents.Chat.OpenRouter do

472 478
  defp consume_event(state, {:ok, %{} = event}, on_event) do
473 479
    with {:ok, state} <- capture_model(state, event["model"]),
474 480
         :ok <- emit_text_delta(event, on_event) do
475
      {:ok, state}
481
      {:ok, capture_provider_metadata(state, event)}
476 482
    end
477 483
  end
478 484

@@ -485,6 +491,23 @@ defmodule OpenAgents.Chat.OpenRouter do

485 491
486 492
  defp capture_model(_state, _model), do: {:error, :invalid_response}
487 493
494
  # Provider-reported evidence only. A missing field stays missing rather than
495
  # becoming a guess the console would present as measured.
496
  defp capture_provider_metadata(state, event) do
497
    state
498
    |> put_metadata(:usage, event["usage"])
499
    |> put_metadata(:request_id, event["id"])
500
    |> put_metadata(:provider, event["provider"])
501
  end
502
503
  defp put_metadata(state, :usage, usage) when is_map(usage), do: %{state | usage: usage}
504
505
  defp put_metadata(state, key, value)
506
       when key in [:request_id, :provider] and is_binary(value) and byte_size(value) in 1..256,
507
       do: Map.put(state, key, value)
508
509
  defp put_metadata(state, _key, _value), do: state
510
488 511
  defp emit_text_delta(%{"choices" => choices}, on_event) when is_list(choices) do
489 512
    choices
490 513
    |> Enum.filter(&(is_map(&1) and Map.get(&1, "index") == 0))

@@ -505,8 +528,25 @@ defmodule OpenAgents.Chat.OpenRouter do

505 528
506 529
  defp emit_text_delta(%{}, _on_event), do: :ok
507 530
508
  defp finish_stream({:ok, %{complete?: true, model: model}}) when is_binary(model),
509
    do: {:ok, %{"object" => "chat.completion", "model" => model}}
531
  defp empty_chat_stream_state,
532
    do: %{
533
      buffer: "",
534
      complete?: false,
535
      model: nil,
536
      usage: nil,
537
      request_id: nil,
538
      provider: nil
539
    }
540
541
  defp finish_stream({:ok, %{complete?: true, model: model} = state}) when is_binary(model) do
542
    completion =
543
      %{"object" => "chat.completion", "model" => model}
544
      |> maybe_put("usage", state.usage)
545
      |> maybe_put("request_id", state.request_id)
546
      |> maybe_put("provider", state.provider)
547
548
    {:ok, completion}
549
  end
510 550
511 551
  defp finish_stream({:ok, _state}), do: {:error, :invalid_response}
512 552
  defp finish_stream({:error, reason}), do: {:error, reason}

@@ -530,8 +570,14 @@ defmodule OpenAgents.Chat.OpenRouter do

530 570
531 571
  defp decode_response(%Req.Response{} = response), do: {:error, provider_failure(response)}
532 572
573
  defp maybe_put(map, _key, nil), do: map
574
  defp maybe_put(map, key, value), do: Map.put(map, key, value)
575
533 576
  defp provider_failure(%Req.Response{status: 429}), do: :rate_limited
534 577
578
  defp provider_failure(%Req.Response{status: status}) when status in [502, 503, 504],
579
    do: :service_unavailable
580
535 581
  defp provider_failure(%Req.Response{body: %Req.Response.Async{} = body}) do
536 582
    body
537 583
    |> async_error_body()
lib/openagents/chat/open_router/responses_stream_decoder.ex modified +23 -1

@@ -171,7 +171,11 @@ defmodule OpenAgents.Chat.OpenRouter.ResponsesStreamDecoder do

171 171
172 172
    case completion(response) do
173 173
      {:ok, completion} ->
174
        completion = merge_streamed_reasoning(completion, state)
174
        completion =
175
          completion
176
          |> merge_streamed_reasoning(state)
177
          |> merge_provider_metadata(response)
178
175 179
        {:ok, %{state | complete?: true, completion: completion}, []}
176 180
177 181
      {:error, :invalid_response} ->

@@ -179,6 +183,24 @@ defmodule OpenAgents.Chat.OpenRouter.ResponsesStreamDecoder do

179 183
    end
180 184
  end
181 185
186
  # Provider-reported evidence only, so the console never presents a guess as a
187
  # measurement. A response without usage stays a response without usage.
188
  defp merge_provider_metadata(completion, response) do
189
    completion
190
    |> put_metadata("usage", response["usage"])
191
    |> put_metadata("request_id", response["id"])
192
    |> put_metadata("provider", response["provider"])
193
  end
194
195
  defp put_metadata(completion, "usage", usage) when is_map(usage),
196
    do: Map.put_new(completion, "usage", usage)
197
198
  defp put_metadata(completion, key, value)
199
       when key in ["request_id", "provider"] and is_binary(value) and byte_size(value) in 1..256,
200
       do: Map.put_new(completion, key, value)
201
202
  defp put_metadata(completion, _key, _value), do: completion
203
182 204
  defp merge_streamed_reasoning(completion, state) do
183 205
    completion =
184 206
      if is_binary(state.reasoning_summary) do
lib/openagents/runtime_supervisor.ex modified +1

@@ -29,6 +29,7 @@ defmodule OpenAgents.RuntimeSupervisor do

29 29
         process_redistribution: :passive,
30 30
         delta_crdt_options: [sync_interval: 150]},
31 31
        {Registry, keys: :unique, name: OpenAgents.TurnRegistry},
32
        {Registry, keys: :unique, name: OpenAgents.Chat.RunRegistry},
32 33
        {DynamicSupervisor, strategy: :one_for_one, name: OpenAgents.TurnSupervisor},
33 34
        {Registry, keys: :unique, name: OpenAgents.VoiceSessionRegistry},
34 35
        {DynamicSupervisor, strategy: :one_for_one, name: OpenAgents.VoiceSessionSupervisor},
lib/openagents_web/live/chat_placeholder_live.ex → lib/openagents_web/live/chat_console_live.ex renamed +256 -232

@@ -1,16 +1,30 @@

1
defmodule OpenAgentsWeb.ChatPlaceholderLive do
1
defmodule OpenAgentsWeb.ChatConsoleLive do
2 2
  @moduledoc """
3
  A local chat preview, reachable at `/chat` by operators only.
4
5
  The preview sends requests to OpenRouter from the server. It prefers the
6
  Responses API and uses Chat Completions only when a provider cannot serve
7
  that API.
3
  The Ox Alpha console, reachable at `/chat` by operators only.
4
5
  The console drives one Ox Alpha conversation per operator account. It sends
6
  every request to OpenRouter from the server, so the provider credential never
7
  reaches the browser, and it prefers the Responses API, using chat completions
8
  only when a provider cannot serve Responses.
9
10
  It shares the AI Elements components with Sarah's transcript at `/sarah` and
11
  shares none of her state: no persona, no voice, no work queue, and no
12
  conversation of hers. The model is fixed, so there is no model picker, and
13
  reasoning, tool calls, usage, and provider lane appear only when the provider
14
  reports them.
8 15
  """
9 16
10 17
  use OpenAgentsWeb, :live_view
11 18
12 19
  alias OpenAgents.Chat.{AccountTurns, OpenRouter}
13 20
21
  @suggestions [
22
    "Summarize what the Ox Alpha stress fleet measures today.",
23
    "Draft a checklist for a cloud-computer stress run.",
24
    "Explain the difference between a push and a deploy here.",
25
    "Write a short status update for the current fleet work."
26
  ]
27
14 28
  @reasoning_options [
15 29
    {"Reasoning off", "none"},
16 30
    {"Minimal reasoning", "minimal"},

@@ -35,9 +49,15 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

35 49
      conversation_content: 1,
36 50
      conversation_empty_state: 1,
37 51
      message: 1,
38
      message_content: 1
52
      message_content: 1,
53
      message_actions: 1,
54
      message_action: 1,
55
      suggestions: 1,
56
      suggestion: 1
39 57
    ]
40 58
59
  import OpenAgentsWeb.AI.Evidence, only: [context: 1]
60
41 61
  import OpenAgentsWeb.AI.Reasoning,
42 62
    only: [
43 63
      reasoning: 1,

@@ -59,6 +79,8 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

59 79
     |> assign(:page_title, "Chat")
60 80
     |> assign(:form, composer_form())
61 81
     |> assign(:reasoning_options, @reasoning_options)
82
     |> assign(:model_label, OpenRouter.model_label())
83
     |> assign(:suggestions, @suggestions)
62 84
     |> assign(:messages, messages)
63 85
     |> assign(:assistant_response, nil)
64 86
     |> assign(:assistant_reasoning, nil)

@@ -66,7 +88,7 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

66 88
     |> assign(:assistant_blocks, [])
67 89
     |> assign(:reasoning_started_at, nil)
68 90
     |> assign(:streaming?, false)
69
     |> assign(:stream_task_ref, nil)
91
     |> assign(:last_prompt, "")
70 92
     |> assign(:stream_id, nil)}
71 93
  end
72 94

@@ -83,6 +105,28 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

83 105
    end
84 106
  end
85 107
108
  def handle_event("stop_response", _params, socket) do
109
    case AccountTurns.cancel(socket.assigns.current_user) do
110
      {:ok, _run} -> {:noreply, reset_stream(socket)}
111
      {:error, :no_active_turn} -> {:noreply, reset_stream(socket)}
112
    end
113
  end
114
115
  def handle_event("retry_message", %{"prompt" => prompt}, socket) do
116
    prompt = String.trim(prompt)
117
    reasoning = current_reasoning(socket)
118
119
    if prompt == "" or socket.assigns.streaming? do
120
      {:noreply, socket}
121
    else
122
      submit_message(socket, prompt, reasoning)
123
    end
124
  end
125
126
  def handle_event("use_suggestion", %{"prompt" => prompt}, socket) do
127
    {:noreply, assign(socket, :form, composer_form(current_reasoning(socket), prompt))}
128
  end
129
86 130
  @impl true
87 131
  def handle_info({:openrouter_stream_event, stream_id, {:text_delta, delta}}, socket) do
88 132
    case socket.assigns do

@@ -126,20 +170,10 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

126 170
    end
127 171
  end
128 172
129
  def handle_info({:account_chat_completed, stream_id, _result}, socket) do
173
  def handle_info({:account_chat_completed, stream_id, result}, socket) do
130 174
    case socket.assigns do
131 175
      %{stream_id: ^stream_id} ->
132
        {:noreply,
133
         socket
134
         |> assign(:messages, AccountTurns.list_messages(socket.assigns.current_user))
135
         |> assign(:assistant_response, nil)
136
         |> assign(:assistant_reasoning, nil)
137
         |> assign(:assistant_tool_calls, [])
138
         |> assign(:assistant_blocks, [])
139
         |> assign(:reasoning_started_at, nil)
140
         |> assign(:streaming?, false)
141
         |> assign(:stream_task_ref, nil)
142
         |> assign(:stream_id, nil)}
176
        {:noreply, socket |> reset_stream() |> restore_prompt(result)}
143 177
144 178
      _stale_run ->
145 179
        {:noreply, socket}

@@ -164,91 +198,6 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

164 198
    end)
165 199
  end
166 200
167
  def handle_info(
168
        {task_ref, {:ok, completion}},
169
        %{assigns: %{stream_task_ref: task_ref}} = socket
170
      ) do
171
    Process.demonitor(task_ref, [:flush])
172
173
    assistant_content = completion["assistant_content"] || socket.assigns.assistant_response
174
    reasoning = completion["reasoning_summary"] || socket.assigns.assistant_reasoning
175
176
    assistant_blocks =
177
      reconcile_assistant_blocks(socket.assigns.assistant_blocks, assistant_content, reasoning)
178
179
    {:noreply,
180
     socket
181
     |> append_assistant_message(
182
       socket.assigns.stream_id,
183
       assistant_content,
184
       completion,
185
       nil,
186
       reasoning,
187
       reasoning_duration(socket),
188
       socket.assigns.assistant_tool_calls,
189
       assistant_blocks
190
     )
191
     |> assign(:assistant_response, nil)
192
     |> assign(:assistant_reasoning, nil)
193
     |> assign(:assistant_tool_calls, [])
194
     |> assign(:assistant_blocks, [])
195
     |> assign(:reasoning_started_at, nil)
196
     |> assign(:streaming?, false)
197
     |> assign(:stream_task_ref, nil)
198
     |> assign(:stream_id, nil)}
199
  end
200
201
  def handle_info({task_ref, {:error, reason}}, %{assigns: %{stream_task_ref: task_ref}} = socket) do
202
    Process.demonitor(task_ref, [:flush])
203
204
    {:noreply,
205
     socket
206
     |> append_assistant_message(
207
       socket.assigns.stream_id,
208
       socket.assigns.assistant_response,
209
       nil,
210
       error_message(reason),
211
       socket.assigns.assistant_reasoning,
212
       reasoning_duration(socket),
213
       socket.assigns.assistant_tool_calls,
214
       finalize_assistant_blocks(socket.assigns.assistant_blocks)
215
     )
216
     |> assign(:assistant_response, nil)
217
     |> assign(:assistant_reasoning, nil)
218
     |> assign(:assistant_tool_calls, [])
219
     |> assign(:assistant_blocks, [])
220
     |> assign(:reasoning_started_at, nil)
221
     |> assign(:streaming?, false)
222
     |> assign(:stream_task_ref, nil)
223
     |> assign(:stream_id, nil)}
224
  end
225
226
  def handle_info(
227
        {:DOWN, task_ref, :process, _pid, _reason},
228
        %{assigns: %{stream_task_ref: task_ref}} = socket
229
      ) do
230
    {:noreply,
231
     socket
232
     |> append_assistant_message(
233
       socket.assigns.stream_id,
234
       socket.assigns.assistant_response,
235
       nil,
236
       error_message(:provider_unavailable),
237
       socket.assigns.assistant_reasoning,
238
       reasoning_duration(socket),
239
       socket.assigns.assistant_tool_calls,
240
       finalize_assistant_blocks(socket.assigns.assistant_blocks)
241
     )
242
     |> assign(:assistant_response, nil)
243
     |> assign(:assistant_reasoning, nil)
244
     |> assign(:assistant_tool_calls, [])
245
     |> assign(:assistant_blocks, [])
246
     |> assign(:reasoning_started_at, nil)
247
     |> assign(:streaming?, false)
248
     |> assign(:stream_task_ref, nil)
249
     |> assign(:stream_id, nil)}
250
  end
251
252 201
  def handle_info(_message, socket), do: {:noreply, socket}
253 202
254 203
  @impl true

@@ -261,49 +210,103 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

261 210
      title="Chat"
262 211
      flush
263 212
    >
264
      <section id="chat-placeholder" class="relative flex min-h-0 flex-1 flex-col bg-background">
213
      <section id="chat-console" class="relative flex min-h-0 flex-1 flex-col bg-background">
214
        <div class="flex items-center gap-3 border-border border-b px-4 py-2">
215
          <span id="chat-console-model" class="font-medium text-sm">{@model_label}</span>
216
          <p id="chat-console-operator-notice" class="text-muted-foreground text-xs">
217
            Operator-only console. Prompts reach {@model_label} through the server.
218
          </p>
219
        </div>
220
265 221
        <div class="flex min-h-0 flex-1 px-4">
266
          <.conversation
267
            id="chat-placeholder-transcript"
268
            class="w-full"
269
            scroll_button={false}
270
          >
222
          <.conversation id="chat-console-transcript" class="w-full">
271 223
            <.conversation_content class={[
272 224
              "mx-auto min-h-full w-full max-w-3xl px-0",
273 225
              if(@messages == [], do: "justify-center", else: "justify-end")
274 226
            ]}>
275
              <.conversation_empty_state
276
                :if={@messages == []}
277
                id="chat-placeholder-empty"
278
                title="Start a conversation"
279
                description="Send a message to start a conversation."
280
              />
227
              <.conversation_empty_state :if={@messages == []} id="chat-console-empty">
228
                <div class="space-y-1">
229
                  <h3 class="font-medium text-sm">Drive {@model_label}</h3>
230
                  <p class="text-muted-foreground text-sm">
231
                    Send a prompt to open a turn. Only operators reach this console.
232
                  </p>
233
                </div>
234
                <.suggestions id="chat-console-suggestions" class="justify-center">
235
                  <.suggestion
236
                    :for={{prompt, index} <- Enum.with_index(@suggestions)}
237
                    id={"chat-console-suggestion-#{index}"}
238
                    suggestion={prompt}
239
                    phx-click="use_suggestion"
240
                    phx-value-prompt={prompt}
241
                  />
242
                </.suggestions>
243
              </.conversation_empty_state>
281 244
282
              <div :if={@messages != []} id="chat-placeholder-exchange" class="space-y-5">
245
              <div :if={@messages != []} id="chat-console-exchange" class="space-y-5">
283 246
                <.message
284 247
                  :for={message <- @messages}
285
                  id={"chat-placeholder-message-#{message.id}-#{message.role}"}
248
                  id={"chat-console-message-#{message.id}-#{message.role}"}
286 249
                  from={Atom.to_string(message.role)}
287 250
                  data-message-role={Atom.to_string(message.role)}
288 251
                >
289 252
                  <%= if message.role == :assistant do %>
290 253
                    <.assistant_block
291 254
                      :for={{block, index} <- Enum.with_index(message.blocks)}
292
                      id={"chat-placeholder-block-#{message.id}-#{index}"}
255
                      id={"chat-console-block-#{message.id}-#{index}"}
293 256
                      block={block}
294 257
                    />
258
                    <p
259
                      :if={Map.get(message, :cancelled?)}
260
                      id={"chat-console-cancelled-#{message.id}"}
261
                      class="text-muted-foreground text-xs"
262
                      role="status"
263
                    >
264
                      You stopped this response.
265
                    </p>
295 266
                    <.message_content :if={message.error}>
296
                      <p id={"chat-placeholder-error-#{message.id}"} role="status">
267
                      <p id={"chat-console-error-#{message.id}"} role="alert">
297 268
                        {message.error}
298 269
                      </p>
299 270
                    </.message_content>
300
                    <p
271
                    <.message_actions :if={Map.get(message, :retryable?) and not @streaming?}>
272
                      <.message_action
273
                        id={"chat-console-retry-#{message.id}"}
274
                        label="Retry this prompt"
275
                        tooltip="Retry this prompt"
276
                        phx-click="retry_message"
277
                        phx-value-prompt={prompt_for(@messages, message.id)}
278
                      >
279
                        <.icon name="regenerate" class="size-4" />
280
                      </.message_action>
281
                    </.message_actions>
282
                    <div
301 283
                      :if={message.completion}
302
                      id={"chat-placeholder-response-metadata-#{message.id}"}
303
                      class="text-muted-foreground text-xs"
284
                      class="flex flex-wrap items-center gap-3"
304 285
                    >
305
                      OpenRouter · {message.completion["object"]} · {message.completion["model"]}
306
                    </p>
286
                      <p
287
                        id={"chat-console-response-metadata-#{message.id}"}
288
                        class="text-muted-foreground text-xs"
289
                      >
290
                        {provider_metadata(@model_label, message)}
291
                      </p>
292
                      <.context
293
                        :if={context_evidence(message)}
294
                        id={"chat-console-evidence-#{message.id}"}
295
                        used_tokens={context_evidence(message).used_tokens}
296
                        max_tokens={context_evidence(message).max_tokens}
297
                        input_tokens={message.usage.input}
298
                        output_tokens={message.usage.output}
299
                        reasoning_tokens={message.usage.reasoning}
300
                        cached_tokens={message.usage.cached}
301
                      />
302
                      <p
303
                        :if={Map.get(message, :usage)}
304
                        id={"chat-console-usage-#{message.id}"}
305
                        class="text-muted-foreground text-xs"
306
                      >
307
                        {usage_text(message.usage)}
308
                      </p>
309
                    </div>
307 310
                  <% else %>
308 311
                    <.message_content text={message.content} />
309 312
                  <% end %>

@@ -311,12 +314,12 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

311 314
312 315
                <.message
313 316
                  :if={@streaming?}
314
                  id="chat-placeholder-streaming-assistant-message"
317
                  id="chat-console-streaming-assistant-message"
315 318
                  from="assistant"
316 319
                >
317 320
                  <.assistant_block
318 321
                    :for={{block, index} <- Enum.with_index(@assistant_blocks)}
319
                    id={"chat-placeholder-streaming-block-#{index}"}
322
                    id={"chat-console-streaming-block-#{index}"}
320 323
                    block={block}
321 324
                    streaming
322 325
                  />

@@ -326,41 +329,41 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

326 329
          </.conversation>
327 330
        </div>
328 331
329
        <div id="chat-placeholder-composer" class="chat-placeholder-composer">
332
        <div id="chat-console-composer" class="chat-console-composer">
330 333
          <.prompt_input
331
            id="chat-placeholder-form"
334
            id="chat-console-form"
332 335
            for={@form}
333
            class="chat-placeholder-composer__form"
336
            class="chat-console-composer__form"
334 337
            phx-submit="submit_message"
335
            clear_event="chat-preview:clear"
338
            clear_event="chat-console:clear"
336 339
            clear_on_submit
337 340
          >
338 341
            <.prompt_input_textarea
339 342
              field={@form[:message]}
340
              placeholder="Message OpenAgents"
341
              aria-label="Message OpenAgents"
343
              placeholder={"Message #{@model_label}"}
344
              aria-label={"Message #{@model_label}"}
342 345
              rows="1"
343 346
              maxlength="8000"
344 347
              autocomplete="off"
345 348
              phx-mounted={JS.focus()}
346 349
            />
347 350
348
            <.prompt_input_toolbar class="chat-placeholder-composer__toolbar">
351
            <.prompt_input_toolbar class="chat-console-composer__toolbar">
349 352
              <.input
350 353
                field={@form[:reasoning]}
351 354
                type="select"
352 355
                options={@reasoning_options}
353 356
                aria-label="Reasoning effort"
354
                class="chat-placeholder-composer__reasoning"
357
                class="chat-console-composer__reasoning"
355 358
                disabled={@streaming?}
356 359
              />
357 360
              <.prompt_input_tools class="ml-auto">
358 361
                <.prompt_input_submit
359
                  id="chat-placeholder-submit"
360
                  status={if(@streaming?, do: :submitted, else: :ready)}
361
                  label="Send message"
362
                  class="chat-placeholder-composer__submit"
363
                  disabled={@streaming?}
362
                  id="chat-console-submit"
363
                  status={if(@streaming?, do: :streaming, else: :ready)}
364
                  label={if(@streaming?, do: "Stop response", else: "Send message")}
365
                  on_stop="stop_response"
366
                  class="chat-console-composer__submit"
364 367
                />
365 368
              </.prompt_input_tools>
366 369
            </.prompt_input_toolbar>

@@ -371,19 +374,92 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

371 374
    """
372 375
  end
373 376
374
  defp composer_form(reasoning \\ "high") do
375
    to_form(%{"message" => "", "reasoning" => reasoning}, as: :chat)
377
  # The console streams through the OpenRouter adapter. A configured streamer
378
  # replaces it, so a test can drive a turn without reaching a provider.
379
  defp submit_options(reasoning) do
380
    options = [reasoning: reasoning, subscriber: self()]
381
382
    case Application.get_env(:openagents, :chat_console_streamer) do
383
      streamer when is_function(streamer, 3) -> Keyword.put(options, :streamer, streamer)
384
      _adapter -> options
385
    end
386
  end
387
388
  defp composer_form(reasoning \\ "high", message \\ "") do
389
    to_form(%{"message" => message, "reasoning" => reasoning}, as: :chat)
390
  end
391
392
  defp current_reasoning(socket) do
393
    OpenRouter.reasoning_effort(Phoenix.HTML.Form.input_value(socket.assigns.form, :reasoning))
394
  end
395
396
  defp reset_stream(socket) do
397
    socket
398
    |> assign(:messages, AccountTurns.list_messages(socket.assigns.current_user))
399
    |> assign(:assistant_response, nil)
400
    |> assign(:assistant_reasoning, nil)
401
    |> assign(:assistant_tool_calls, [])
402
    |> assign(:assistant_blocks, [])
403
    |> assign(:reasoning_started_at, nil)
404
    |> assign(:streaming?, false)
405
    |> assign(:stream_id, nil)
406
  end
407
408
  # A failed turn keeps its prompt in the composer, so the operator can retry it
409
  # from either the composer or the failed message.
410
  defp restore_prompt(socket, {:error, _reason}) do
411
    assign(socket, :form, composer_form(current_reasoning(socket), socket.assigns.last_prompt))
412
  end
413
414
  defp restore_prompt(socket, _result), do: socket
415
416
  defp prompt_for(messages, id) do
417
    case Enum.find(messages, &(&1.id == id and &1.role == :user)) do
418
      %{content: content} -> content
419
      nil -> nil
420
    end
421
  end
422
423
  defp provider_metadata(model_label, message) do
424
    [
425
      model_label,
426
      message.provider_lane && "lane #{message.provider_lane}",
427
      message.latency_ms && "#{message.latency_ms} ms",
428
      message.request_id && "request #{message.request_id}"
429
    ]
430
    |> Enum.reject(&is_nil/1)
431
    |> Enum.join(" · ")
432
  end
433
434
  # The context meter needs a window the provider does not report, so it appears
435
  # only where the deployment states one.
436
  defp context_evidence(%{usage: %{total: total}}) when is_integer(total) do
437
    case Application.get_env(:openagents, :openrouter_context_window) do
438
      window when is_integer(window) and window > 0 -> %{used_tokens: total, max_tokens: window}
439
      _unset -> nil
440
    end
441
  end
442
443
  defp context_evidence(_message), do: nil
444
445
  defp usage_text(usage) do
446
    [
447
      usage.input && "Input #{usage.input}",
448
      usage.output && "Output #{usage.output}",
449
      usage.reasoning && "Reasoning #{usage.reasoning}",
450
      usage.cached && "Cached #{usage.cached}"
451
    ]
452
    |> Enum.reject(&is_nil/1)
453
    |> Enum.join(" · ")
376 454
  end
377 455
378 456
  defp submit_message(socket, message, reasoning) do
379
    case AccountTurns.submit(socket.assigns.current_user, message,
380
           reasoning: reasoning,
381
           subscriber: self()
382
         ) do
457
    case AccountTurns.submit(socket.assigns.current_user, message, submit_options(reasoning)) do
383 458
      {:ok, run} ->
384 459
        {:noreply,
385 460
         socket
386 461
         |> assign(:form, composer_form(reasoning))
462
         |> assign(:last_prompt, message)
387 463
         |> assign(:messages, AccountTurns.list_messages(socket.assigns.current_user))
388 464
         |> assign(:assistant_response, "")
389 465
         |> assign(:assistant_reasoning, nil)

@@ -391,37 +467,49 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

391 467
         |> assign(:assistant_blocks, [reasoning_block("")])
392 468
         |> assign(:reasoning_started_at, System.monotonic_time(:second))
393 469
         |> assign(:streaming?, true)
394
         |> assign(:stream_task_ref, nil)
395 470
         |> assign(:stream_id, run["id"])
396
         |> push_event("chat-preview:clear", %{})}
471
         |> push_event("chat-console:clear", %{})}
397 472
398 473
      {:error, reason} ->
474
        id = Ecto.UUID.generate()
475
399 476
        {:noreply,
400 477
         socket
478
         |> assign(:form, composer_form(reasoning, message))
401 479
         |> update(:messages, fn messages ->
402
           messages ++
403
             [
404
               user_message(Ecto.UUID.generate(), message),
405
               %{
406
                 id: Ecto.UUID.generate(),
407
                 role: :assistant,
408
                 content: "",
409
                 completion: nil,
410
                 error: error_message(reason),
411
                 history?: false,
412
                 provider_message_id: nil,
413
                 provider_status: nil,
414
                 provider_reasoning_items: nil,
415
                 reasoning: nil,
416
                 reasoning_duration: nil,
417
                 tool_calls: [],
418
                 blocks: []
419
               }
420
             ]
480
           messages ++ [user_message(id, message), failed_message(id, reason)]
421 481
         end)}
422 482
    end
423 483
  end
424 484
485
  defp failed_message(id, reason),
486
    do: %{
487
      id: id,
488
      role: :assistant,
489
      content: "",
490
      completion: nil,
491
      error: error_message(reason),
492
      error_code: error_code(reason),
493
      retryable?: AccountTurns.retryable_error_code?(error_code(reason)),
494
      cancelled?: false,
495
      usage: nil,
496
      provider_lane: nil,
497
      request_id: nil,
498
      latency_ms: nil,
499
      history?: false,
500
      provider_message_id: nil,
501
      provider_status: nil,
502
      provider_reasoning_items: nil,
503
      reasoning: nil,
504
      reasoning_duration: nil,
505
      tool_calls: [],
506
      blocks: []
507
    }
508
509
  defp error_code(reason) when is_atom(reason), do: Atom.to_string(reason)
510
  defp error_code({:provider_error, code, _detail}) when is_binary(code), do: code
511
  defp error_code(_reason), do: "provider_error"
512
425 513
  defp user_message(id, content),
426 514
    do: %{
427 515
      id: id,

@@ -434,36 +522,6 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

434 522
      blocks: []
435 523
    }
436 524
437
  defp append_assistant_message(
438
         socket,
439
         id,
440
         content,
441
         completion,
442
         error,
443
         reasoning,
444
         reasoning_duration,
445
         tool_calls,
446
         blocks
447
       ) do
448
    assistant = %{
449
      id: id,
450
      role: :assistant,
451
      content: content,
452
      completion: completion,
453
      error: error,
454
      history?: is_nil(error),
455
      provider_message_id: completion && completion["assistant_message_id"],
456
      provider_status: if(completion && completion["assistant_message_id"], do: "completed"),
457
      provider_reasoning_items: completion && completion["reasoning_items"],
458
      reasoning: reasoning,
459
      reasoning_duration: reasoning_duration,
460
      tool_calls: tool_calls,
461
      blocks: blocks
462
    }
463
464
    update(socket, :messages, &(&1 ++ [assistant]))
465
  end
466
467 525
  defp error_message(:missing_api_key), do: "OpenRouter is not configured for this environment."
468 526
  defp error_message(:rate_limited), do: "OpenRouter is rate-limited. Try again later."
469 527

@@ -487,13 +545,6 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

487 545
  defp provider_error_message(summary, nil), do: summary
488 546
  defp provider_error_message(summary, detail), do: "#{summary} #{detail}"
489 547
490
  defp reasoning_duration(%{assigns: %{reasoning_started_at: started_at}})
491
       when is_integer(started_at) do
492
    max(System.monotonic_time(:second) - started_at, 1)
493
  end
494
495
  defp reasoning_duration(_socket), do: nil
496
497 548
  attr :id, :string, required: true
498 549
  attr :tool_call, :map, required: true
499 550
  attr :open, :boolean, default: false

@@ -650,31 +701,4 @@ defmodule OpenAgentsWeb.ChatPlaceholderLive do

650 701
    end)
651 702
    |> Enum.reject(&(&1.type == :reasoning and &1.text == ""))
652 703
  end
653
654
  defp reconcile_assistant_blocks(blocks, content, reasoning) do
655
    blocks = finalize_assistant_blocks(blocks)
656
657
    blocks =
658
      if Enum.any?(blocks, &(&1.type == :reasoning)) or not is_binary(reasoning) or
659
           reasoning == "" do
660
        blocks
661
      else
662
        [%{reasoning_block(reasoning) | duration: 1} | blocks]
663
      end
664
665
    case Enum.find_index(Enum.reverse(blocks), &(&1.type == :content)) do
666
      nil when is_binary(content) and content != "" ->
667
        blocks ++ [%{type: :content, text: content}]
668
669
      nil ->
670
        blocks
671
672
      reversed_index when is_binary(content) ->
673
        index = length(blocks) - reversed_index - 1
674
        List.update_at(blocks, index, &%{&1 | text: content})
675
676
      _index ->
677
        blocks
678
    end
679
  end
680 704
end
lib/openagents_web/router.ex modified +1 -1

@@ -174,7 +174,7 @@ defmodule OpenAgentsWeb.Router do

174 174
        {OpenAgentsWeb.UserAuth, :ensure_authenticated},
175 175
        {OpenAgentsWeb.UserAuth, :ensure_admin}
176 176
      ] do
177
      live "/chat", ChatPlaceholderLive, :index
177
      live "/chat", ChatConsoleLive, :index
178 178
    end
179 179
  end
180 180
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -238,7 +238,8 @@

238 238
    20260823034851,
239 239
    20260823040635,
240 240
    20260823042207,
241
    20260823043000
241
    20260823043000,
242
    20260823050000
242 243
  ],
243 244
  "required_tables": [
244 245
    "users",
priv/repo/migrations/20260823050000_add_cancelled_status_to_account_chat_runs.exs added +35

@@ -0,0 +1,35 @@

1
defmodule OpenAgents.Repo.Migrations.AddCancelledStatusToAccountChatRuns do
2
  use Ecto.Migration
3
4
  def up do
5
    drop constraint(:account_chat_runs, :account_chat_runs_status)
6
7
    create constraint(:account_chat_runs, :account_chat_runs_status,
8
             check: "status IN ('streaming', 'completed', 'failed', 'cancelled')"
9
           )
10
11
    alter table(:account_chat_runs) do
12
      add :error_code, :string
13
      add :latency_ms, :integer
14
      # Redaction blanks every field whose name contains `token`, which token
15
      # counts do. The counts the provider reported live here instead.
16
      add :usage, :map
17
    end
18
  end
19
20
  def down do
21
    alter table(:account_chat_runs) do
22
      remove :error_code
23
      remove :latency_ms
24
      remove :usage
25
    end
26
27
    execute "UPDATE account_chat_runs SET status = 'failed' WHERE status = 'cancelled'"
28
29
    drop constraint(:account_chat_runs, :account_chat_runs_status)
30
31
    create constraint(:account_chat_runs, :account_chat_runs_status,
32
             check: "status IN ('streaming', 'completed', 'failed')"
33
           )
34
  end
35
end
test/openagents/chat/account_turns_test.exs modified +10

@@ -3,6 +3,16 @@ defmodule OpenAgents.Chat.AccountTurnsTest do

3 3
4 4
  alias OpenAgents.Chat.AccountTurns
5 5
6
  test "an operator can retry the provider failures a retry can fix" do
7
    for code <- ~w(rate_limited service_unavailable stream_interrupted invalid_response
8
                   provider_unavailable server_error) do
9
      assert AccountTurns.retryable_error_code?(code)
10
    end
11
12
    refute AccountTurns.retryable_error_code?("missing_api_key")
13
    refute AccountTurns.retryable_error_code?(nil)
14
  end
15
6 16
  test "submit journals the provider lifecycle and projects the same ordered messages" do
7 17
    user = repository_user_fixture("account-chat-journal")
8 18
test/openagents/chat/open_router_test.exs modified +133

@@ -299,6 +299,130 @@ defmodule OpenAgents.Chat.OpenRouterTest do

299 299
             )
300 300
  end
301 301
302
  test "normalizes an unavailable provider" do
303
    Req.Test.expect(__MODULE__, fn conn ->
304
      assert conn.request_path == "/api/v1/responses"
305
      Plug.Conn.send_resp(conn, 503, "service unavailable")
306
    end)
307
308
    assert {:error, :service_unavailable} = stream_hello()
309
  end
310
311
  test "normalizes a rate-limited stream" do
312
    Req.Test.expect(__MODULE__, fn conn -> Plug.Conn.send_resp(conn, 429, "rate limited") end)
313
314
    assert {:error, :rate_limited} = stream_hello()
315
  end
316
317
  test "reports a transport failure before the stream starts" do
318
    Req.Test.expect(__MODULE__, fn conn -> Req.Test.transport_error(conn, :econnrefused) end)
319
320
    assert {:error, :provider_unavailable} = stream_hello()
321
  end
322
323
  test "reports a transport failure that interrupts a started stream" do
324
    Req.Test.expect(__MODULE__, fn conn ->
325
      conn =
326
        conn
327
        |> Plug.Conn.put_resp_content_type("text/event-stream")
328
        |> Plug.Conn.send_chunked(200)
329
330
      {:ok, conn} =
331
        Plug.Conn.chunk(conn, sse(%{"type" => "response.content_part.delta", "delta" => "Half"}))
332
333
      Req.Test.transport_error(conn, :closed)
334
    end)
335
336
    assert {:error, :provider_unavailable} = stream_hello()
337
  end
338
339
  test "reports a stream that ends before the provider finishes the response" do
340
    Req.Test.expect(__MODULE__, fn conn ->
341
      conn
342
      |> Plug.Conn.put_resp_content_type("text/event-stream")
343
      |> Plug.Conn.send_resp(
344
        200,
345
        sse(%{"type" => "response.content_part.delta", "delta" => "Half"})
346
      )
347
    end)
348
349
    assert {:error, :invalid_response} = stream_hello()
350
  end
351
352
  test "rejects a malformed provider event" do
353
    Req.Test.expect(__MODULE__, fn conn ->
354
      conn
355
      |> Plug.Conn.put_resp_content_type("text/event-stream")
356
      |> Plug.Conn.send_resp(200, "data: {\"type\": \n\n")
357
    end)
358
359
    assert {:error, :invalid_response} = stream_hello()
360
  end
361
362
  test "keeps the usage, request, and provider fields a chat completions stream reports" do
363
    Req.Test.expect(__MODULE__, fn conn -> Plug.Conn.send_resp(conn, 404, "not found") end)
364
365
    Req.Test.expect(__MODULE__, fn conn ->
366
      body =
367
        sse(%{
368
          "id" => "gen-metadata",
369
          "object" => "chat.completion.chunk",
370
          "model" => "stealth/ox-alpha",
371
          "provider" => "Stealth",
372
          "choices" => [%{"index" => 0, "delta" => %{"content" => "Measured"}}]
373
        }) <>
374
          sse(%{
375
            "id" => "gen-metadata",
376
            "object" => "chat.completion.chunk",
377
            "model" => "stealth/ox-alpha",
378
            "choices" => [],
379
            "usage" => %{
380
              "prompt_tokens" => 12,
381
              "completion_tokens" => 3,
382
              "total_tokens" => 15
383
            }
384
          }) <> "data: [DONE]\n\n"
385
386
      conn
387
      |> Plug.Conn.put_resp_content_type("text/event-stream")
388
      |> Plug.Conn.send_resp(200, body)
389
    end)
390
391
    assert {:ok, completion} = stream_hello()
392
393
    assert completion["request_id"] == "gen-metadata"
394
    assert completion["provider"] == "Stealth"
395
396
    assert completion["usage"] == %{
397
             "prompt_tokens" => 12,
398
             "completion_tokens" => 3,
399
             "total_tokens" => 15
400
           }
401
  end
402
403
  test "omits the usage a chat completions stream never reports" do
404
    Req.Test.expect(__MODULE__, fn conn -> Plug.Conn.send_resp(conn, 404, "not found") end)
405
406
    Req.Test.expect(__MODULE__, fn conn ->
407
      body =
408
        sse(%{
409
          "object" => "chat.completion.chunk",
410
          "model" => "stealth/ox-alpha",
411
          "choices" => [%{"index" => 0, "delta" => %{"content" => "Quiet"}}]
412
        }) <> "data: [DONE]\n\n"
413
414
      conn
415
      |> Plug.Conn.put_resp_content_type("text/event-stream")
416
      |> Plug.Conn.send_resp(200, body)
417
    end)
418
419
    assert {:ok, completion} = stream_hello()
420
421
    refute Map.has_key?(completion, "usage")
422
    refute Map.has_key?(completion, "request_id")
423
    refute Map.has_key?(completion, "provider")
424
  end
425
302 426
  test "prefers the Responses API and streams structured history" do
303 427
    Req.Test.expect(__MODULE__, fn conn ->
304 428
      assert conn.request_path == "/api/v1/responses"

@@ -1058,4 +1182,13 @@ defmodule OpenAgents.Chat.OpenRouterTest do

1058 1182
  end
1059 1183
1060 1184
  defp sse(event), do: "data: " <> Jason.encode!(event) <> "\n\n"
1185
1186
  defp stream_hello do
1187
    OpenRouter.stream(
1188
      %{"model" => "stealth/ox-alpha", "messages" => [%{"role" => "user", "content" => "Hello"}]},
1189
      fn _event -> :ok end,
1190
      api_key: "test-openrouter-key",
1191
      request_options: [plug: {Req.Test, __MODULE__}]
1192
    )
1193
  end
1061 1194
end
test/openagents_web/live/chat_console_test.exs added +412

@@ -0,0 +1,412 @@

1
defmodule OpenAgentsWeb.ChatConsoleTest do
2
  @moduledoc """
3
  `/chat` is the operator-only Ox Alpha console.
4
5
  The gate is worth its own file because it fails in two quiet directions: a
6
  missing plug lets a signed-in non-operator read the page, and a missing
7
  on_mount hook lets a LiveView event run on a socket that never passed the
8
  gate. The turn states carry the rest of the file: an empty console, a turn in
9
  flight, a completed turn with the evidence the provider reported, a stopped
10
  turn, and a failed turn whose prompt survives.
11
  """
12
13
  use OpenAgentsWeb.ConnCase
14
  import Phoenix.LiveViewTest
15
16
  alias OpenAgents.Chat.AccountTurns
17
18
  # The console streams through OpenRouter. These tests replace that function so
19
  # a turn reaches its states without a provider.
20
  defp stub_streamer(streamer) do
21
    Application.put_env(:openagents, :chat_console_streamer, streamer)
22
    on_exit(fn -> Application.delete_env(:openagents, :chat_console_streamer) end)
23
  end
24
25
  test "an anonymous request is sent home", %{conn: conn} do
26
    conn = get(conn, ~p"/chat")
27
28
    assert redirected_to(conn) == ~p"/"
29
  end
30
31
  test "a signed-in non-operator is sent home", %{conn: conn} do
32
    conn = log_in_github_user(conn, "not-an-operator")
33
    conn = get(conn, ~p"/chat")
34
35
    assert redirected_to(conn) == ~p"/"
36
  end
37
38
  test "an operator gets the empty console", %{conn: conn} do
39
    conn = log_in_admin_user(conn, "console-operator")
40
    {:ok, view, _html} = live(conn, ~p"/chat")
41
42
    assert has_element?(view, "#chat-console-transcript")
43
    assert has_element?(view, "#chat-console-empty")
44
    assert has_element?(view, "#chat-console-model", "Ox Alpha")
45
    assert has_element?(view, "#chat-console-operator-notice", "Operator-only console")
46
    assert has_element?(view, "#chat-console-suggestions")
47
    assert has_element?(view, "#chat-console-suggestion-0")
48
    assert has_element?(view, ~s(a[href="/sarah"]))
49
    assert has_element?(view, ~s(#chat-console-form[data-submit-on-enter="true"]))
50
    assert has_element?(view, ~s(#chat-console-form[data-clear-event="chat-console:clear"]))
51
    assert has_element?(view, ~s(#chat_message[phx-mounted]))
52
    assert has_element?(view, ~s(#chat_reasoning[name="chat[reasoning]"]))
53
    assert has_element?(view, "#chat-console-submit")
54
  end
55
56
  test "a suggestion fills the composer", %{conn: conn} do
57
    conn = log_in_admin_user(conn, "console-suggestion-operator")
58
    {:ok, view, _html} = live(conn, ~p"/chat")
59
60
    html = view |> element("#chat-console-suggestion-0") |> render_click()
61
62
    assert html =~ "Ox Alpha stress fleet measures"
63
  end
64
65
  test "a turn in flight can be stopped", %{conn: conn} do
66
    stub_streamer(fn _request, callback, _options ->
67
      callback.({:text_delta, "Working"})
68
69
      receive do
70
        :never -> {:ok, %{}}
71
      end
72
    end)
73
74
    conn = log_in_admin_user(conn, "console-streaming-operator")
75
    {:ok, view, _html} = live(conn, ~p"/chat")
76
77
    view
78
    |> form("#chat-console-form", %{"chat" => %{"message" => "Stress the fleet."}})
79
    |> render_submit()
80
81
    assert has_element?(view, "#chat-console-streaming-assistant-message")
82
    assert has_element?(view, ~s(#chat-console-submit[aria-label="Stop response"]))
83
84
    view |> element("#chat-console-submit") |> render_click()
85
86
    refute has_element?(view, "#chat-console-streaming-assistant-message")
87
    assert has_element?(view, ~s([id^="chat-console-cancelled-"]), "You stopped this response")
88
  end
89
90
  test "a completed turn shows the evidence the provider reported", %{conn: conn} do
91
    key = "console-completed-operator"
92
    user = github_user(key)
93
    conn = log_in_admin_user(conn, key)
94
95
    streamer = fn _request, callback, _options ->
96
      callback.({:text_delta, "The fleet is idle."})
97
98
      {:ok,
99
       %{
100
         "object" => "chat.completion",
101
         "model" => "stealth/ox-alpha",
102
         "assistant_content" => "The fleet is idle.",
103
         "provider" => "Stealth",
104
         "request_id" => "gen-123",
105
         "usage" => %{
106
           "prompt_tokens" => 24,
107
           "completion_tokens" => 8,
108
           "total_tokens" => 32,
109
           "completion_tokens_details" => %{"reasoning_tokens" => 3},
110
           "prompt_tokens_details" => %{"cached_tokens" => 4}
111
         }
112
       }}
113
    end
114
115
    assert {:ok, %{"id" => run_id}} =
116
             AccountTurns.submit(user, "Report the fleet.",
117
               subscriber: self(),
118
               streamer: streamer
119
             )
120
121
    assert_receive {:account_chat_completed, ^run_id, {:ok, _completion}}
122
    {:ok, view, _html} = live(conn, ~p"/chat")
123
124
    metadata = "#chat-console-response-metadata-#{run_id}"
125
    assert has_element?(view, metadata, "Ox Alpha")
126
    assert has_element?(view, metadata, "lane Stealth")
127
    assert has_element?(view, metadata, "request gen-123")
128
    assert has_element?(view, metadata, "ms")
129
    assert has_element?(view, "#chat-console-usage-#{run_id}", "Input 24")
130
    assert has_element?(view, "#chat-console-usage-#{run_id}", "Output 8")
131
    assert has_element?(view, "#chat-console-usage-#{run_id}", "Reasoning 3")
132
    assert has_element?(view, "#chat-console-usage-#{run_id}", "Cached 4")
133
    refute has_element?(view, "#chat-console-evidence-#{run_id}")
134
  end
135
136
  test "a completed turn shows a context meter where a window is configured", %{conn: conn} do
137
    key = "console-context-operator"
138
    user = github_user(key)
139
    conn = log_in_admin_user(conn, key)
140
    Application.put_env(:openagents, :openrouter_context_window, 128_000)
141
    on_exit(fn -> Application.put_env(:openagents, :openrouter_context_window, nil) end)
142
143
    streamer = fn _request, _callback, _options ->
144
      {:ok,
145
       %{
146
         "assistant_content" => "Done.",
147
         "usage" => %{"total_tokens" => 640, "prompt_tokens" => 600, "completion_tokens" => 40}
148
       }}
149
    end
150
151
    assert {:ok, %{"id" => run_id}} =
152
             AccountTurns.submit(user, "Measure the window.",
153
               subscriber: self(),
154
               streamer: streamer
155
             )
156
157
    assert_receive {:account_chat_completed, ^run_id, {:ok, _completion}}
158
    {:ok, view, _html} = live(conn, ~p"/chat")
159
160
    assert has_element?(view, "#chat-console-evidence-#{run_id}")
161
  end
162
163
  test "a rate-limited turn is retryable", %{conn: conn} do
164
    key = "console-retry-operator"
165
    user = github_user(key)
166
    conn = log_in_admin_user(conn, key)
167
168
    streamer = fn _request, _callback, _options -> {:error, :rate_limited} end
169
170
    assert {:ok, %{"id" => run_id}} =
171
             AccountTurns.submit(user, "Retry me.", subscriber: self(), streamer: streamer)
172
173
    assert_receive {:account_chat_completed, ^run_id, {:error, :rate_limited}}
174
    {:ok, view, _html} = live(conn, ~p"/chat")
175
176
    assert has_element?(view, "#chat-console-error-#{run_id}", "rate-limited")
177
    assert has_element?(view, ~s(#chat-console-error-#{run_id}[role="alert"]))
178
179
    assert has_element?(
180
             view,
181
             ~s(#chat-console-retry-#{run_id}[phx-value-prompt="Retry me."])
182
           )
183
  end
184
185
  test "a malformed provider stream is retryable", %{conn: conn} do
186
    key = "console-malformed-operator"
187
    user = github_user(key)
188
    conn = log_in_admin_user(conn, key)
189
190
    streamer = fn _request, _callback, _options -> {:error, :invalid_response} end
191
192
    assert {:ok, %{"id" => run_id}} =
193
             AccountTurns.submit(user, "Send a broken event.",
194
               subscriber: self(),
195
               streamer: streamer
196
             )
197
198
    assert_receive {:account_chat_completed, ^run_id, {:error, :invalid_response}}
199
    {:ok, view, _html} = live(conn, ~p"/chat")
200
201
    assert has_element?(view, "#chat-console-error-#{run_id}")
202
    assert has_element?(view, "#chat-console-retry-#{run_id}")
203
  end
204
205
  test "the composer shows a safe inline error when OpenRouter is not configured", %{conn: conn} do
206
    conn = log_in_admin_user(conn, "console-composer-operator")
207
    {:ok, view, _html} = live(conn, ~p"/chat")
208
209
    view
210
    |> form("#chat-console-form", %{"chat" => %{"message" => "Draft the release notes."}})
211
    |> render_submit()
212
213
    assert has_element?(
214
             view,
215
             ~s([data-message-role="user"]),
216
             "Draft the release notes."
217
           )
218
219
    assert has_element?(view, ~s([role="alert"]), "OpenRouter is not configured")
220
    assert has_element?(view, ~s(#chat_message), "Draft the release notes.")
221
  end
222
223
  test "a non-operator on a live socket is still turned away by the mount hook", %{conn: conn} do
224
    # The plug gate runs on the initial document request; this drives the
225
    # LiveView mount directly so the on_mount hook is what answers.
226
    conn = log_in_chatting_user(conn, "socket-not-an-operator")
227
228
    assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/chat")
229
  end
230
231
  test "reasoning stays interleaved with successive tool attempts" do
232
    socket =
233
      %Phoenix.LiveView.Socket{}
234
      |> Phoenix.Component.assign(:stream_id, 42)
235
      |> Phoenix.Component.assign(:streaming?, true)
236
      |> Phoenix.Component.assign(:assistant_reasoning, nil)
237
      |> Phoenix.Component.assign(:assistant_tool_calls, [])
238
      |> Phoenix.Component.assign(:assistant_blocks, [])
239
240
    {:noreply, socket} =
241
      OpenAgentsWeb.ChatConsoleLive.handle_info(
242
        {:openrouter_stream_event, 42, {:reasoning_delta, "First attempt."}},
243
        socket
244
      )
245
246
    {:noreply, socket} =
247
      OpenAgentsWeb.ChatConsoleLive.handle_info(
248
        {:openrouter_stream_event, 42,
249
         {:tool_call_started,
250
          %{
251
            "call_id" => "call-1",
252
            "name" => "read_repository_file",
253
            "arguments" => ~s({"path":"null"})
254
          }}},
255
        socket
256
      )
257
258
    {:noreply, socket} =
259
      OpenAgentsWeb.ChatConsoleLive.handle_info(
260
        {:openrouter_stream_event, 42,
261
         {:tool_call_failed, %{"call_id" => "call-1", "error" => "Not found"}}},
262
        socket
263
      )
264
265
    {:noreply, socket} =
266
      OpenAgentsWeb.ChatConsoleLive.handle_info(
267
        {:openrouter_stream_event, 42, {:reasoning_delta, "Second attempt."}},
268
        socket
269
      )
270
271
    {:noreply, socket} =
272
      OpenAgentsWeb.ChatConsoleLive.handle_info(
273
        {:openrouter_stream_event, 42,
274
         {:tool_call_started,
275
          %{
276
            "call_id" => "call-2",
277
            "name" => "read_repository_file",
278
            "arguments" => ~s({"path":"README.md"})
279
          }}},
280
        socket
281
      )
282
283
    {:noreply, socket} =
284
      OpenAgentsWeb.ChatConsoleLive.handle_info(
285
        {:openrouter_stream_event, 42,
286
         {:tool_call_failed, %{"call_id" => "call-2", "error" => "Still not found"}}},
287
        socket
288
      )
289
290
    {:noreply, socket} =
291
      OpenAgentsWeb.ChatConsoleLive.handle_info(
292
        {:openrouter_stream_event, 42, {:reasoning_delta, "Report the failure."}},
293
        socket
294
      )
295
296
    assert [first_reasoning, first_tool, second_reasoning, second_tool, final_reasoning] =
297
             socket.assigns.assistant_blocks
298
299
    assert first_reasoning.type == :reasoning
300
    assert first_reasoning.text == "First attempt."
301
    assert is_integer(first_reasoning.duration)
302
    assert first_tool.type == :tool
303
    assert first_tool.tool_call.state == "output-error"
304
    assert first_tool.tool_call.error == "Not found"
305
    assert second_reasoning.type == :reasoning
306
    assert second_reasoning.text == "Second attempt."
307
    assert is_integer(second_reasoning.duration)
308
    assert second_tool.type == :tool
309
    assert second_tool.tool_call.state == "output-error"
310
    assert second_tool.tool_call.error == "Still not found"
311
    assert final_reasoning.type == :reasoning
312
    assert final_reasoning.text == "Report the failure."
313
    assert is_nil(final_reasoning.duration)
314
  end
315
316
  test "the browser renders durable tool workspace, duration, output, and receipts", %{conn: conn} do
317
    key = "placeholder-tool-metadata"
318
    user = github_user(key)
319
    conn = log_in_admin_user(conn, key)
320
321
    streamer = fn _request, callback, _options ->
322
      callback.(
323
        {:tool_call_started,
324
         %{
325
           "call_id" => "call-read",
326
           "name" => "read",
327
           "arguments" => ~s({"path":"README.md"})
328
         }}
329
      )
330
331
      callback.(
332
        {:tool_call_completed,
333
         %{
334
           "call_id" => "call-read",
335
           "output" => %{
336
             "schema" => "sarah.tool_outcome.v1",
337
             "status" => "succeeded",
338
             "result" => %{"content" => "OpenAgents"},
339
             "workspace" => %{
340
               "type" => "forge_worktree",
341
               "path" => "/private/var/lib/openagents/workspaces/repo"
342
             },
343
             "target_receipt_refs" => ["receipt:read:1"],
344
             "started_at" => "2026-08-22T19:43:28.000Z",
345
             "completed_at" => "2026-08-22T19:43:28.025Z"
346
           }
347
         }}
348
      )
349
350
      {:ok, %{"assistant_content" => "Read the file."}}
351
    end
352
353
    assert {:ok, %{"id" => run_id}} =
354
             OpenAgents.Chat.AccountTurns.submit(user, "Read the README.",
355
               subscriber: self(),
356
               streamer: streamer
357
             )
358
359
    assert_receive {:account_chat_completed, ^run_id, {:ok, _completion}}
360
    {:ok, view, _html} = live(conn, ~p"/chat")
361
362
    block_id = "chat-console-block-#{run_id}-0"
363
    assert has_element?(view, "##{block_id}-metadata", "succeeded")
364
    assert has_element?(view, "##{block_id}-metadata", "repo")
365
    refute render(view) =~ "/private/var/lib/openagents"
366
    assert has_element?(view, "##{block_id}-metadata", "25 ms")
367
    assert has_element?(view, "##{block_id}-output", "OpenAgents")
368
    assert has_element?(view, "##{block_id}-receipts", "receipt:read:1")
369
  end
370
371
  test "streaming typed tool failures retain status and error code" do
372
    socket =
373
      %Phoenix.LiveView.Socket{}
374
      |> Phoenix.Component.assign(:stream_id, 84)
375
      |> Phoenix.Component.assign(:streaming?, true)
376
      |> Phoenix.Component.assign(:assistant_tool_calls, [])
377
      |> Phoenix.Component.assign(:assistant_blocks, [])
378
379
    {:noreply, socket} =
380
      OpenAgentsWeb.ChatConsoleLive.handle_info(
381
        {:openrouter_stream_event, 84,
382
         {:tool_call_started, %{"call_id" => "call-edit", "name" => "edit", "arguments" => "{}"}}},
383
        socket
384
      )
385
386
    {:noreply, socket} =
387
      OpenAgentsWeb.ChatConsoleLive.handle_info(
388
        {:openrouter_stream_event, 84,
389
         {:tool_call_failed,
390
          %{
391
            "call_id" => "call-edit",
392
            "output" => %{
393
              "schema" => "sarah.tool_outcome.v1",
394
              "status" => "failed",
395
              "error" => %{
396
                "code" => "workspace_read_only",
397
                "message" => "The workspace is read-only."
398
              },
399
              "workspace" => %{"path" => "/private/var/lib/openagents/workspaces/repo"}
400
            }
401
          }}},
402
        socket
403
      )
404
405
    assert [%{tool_call: tool}] = socket.assigns.assistant_blocks
406
    assert tool.status == "failed"
407
    assert tool.state == "output-error"
408
    assert tool.error_code == "workspace_read_only"
409
    assert tool.error == "The workspace is read-only."
410
    assert tool.workspace_label == "repo"
411
  end
412
end
test/openagents_web/live/chat_placeholder_test.exs deleted -247

@@ -1,247 +0,0 @@

1
defmodule OpenAgentsWeb.ChatPlaceholderTest do
2
  @moduledoc """
3
  `/chat` is the operator-only placeholder for the upcoming chat surface.
4
5
  The gate is worth its own file because it fails in two quiet directions: a
6
  missing plug lets a signed-in non-operator read the page, and a missing
7
  on_mount hook lets a LiveView event run on a socket that never passed the
8
  gate.
9
  """
10
11
  use OpenAgentsWeb.ConnCase
12
  import Phoenix.LiveViewTest
13
14
  test "an anonymous request is sent home", %{conn: conn} do
15
    conn = get(conn, ~p"/chat")
16
17
    assert redirected_to(conn) == ~p"/"
18
  end
19
20
  test "a signed-in non-operator is sent home", %{conn: conn} do
21
    conn = log_in_github_user(conn, "not-an-operator")
22
    conn = get(conn, ~p"/chat")
23
24
    assert redirected_to(conn) == ~p"/"
25
  end
26
27
  test "an operator gets the placeholder", %{conn: conn} do
28
    conn = log_in_admin_user(conn, "placeholder-operator")
29
    {:ok, view, _html} = live(conn, ~p"/chat")
30
31
    assert has_element?(view, "#chat-placeholder-transcript")
32
    assert has_element?(view, "#chat-placeholder-empty")
33
    assert has_element?(view, ~s(a[href="/sarah"]))
34
    assert has_element?(view, ~s(#chat-placeholder-form[data-submit-on-enter="true"]))
35
    assert has_element?(view, ~s(#chat-placeholder-form[data-clear-event="chat-preview:clear"]))
36
    assert has_element?(view, ~s(#chat_message[phx-mounted]))
37
    assert has_element?(view, ~s(#chat_reasoning[name="chat[reasoning]"]))
38
    assert has_element?(view, "#chat-placeholder-submit")
39
  end
40
41
  test "the composer shows a safe inline error when OpenRouter is not configured", %{conn: conn} do
42
    conn = log_in_admin_user(conn, "placeholder-composer-operator")
43
    {:ok, view, _html} = live(conn, ~p"/chat")
44
45
    view
46
    |> form("#chat-placeholder-form", %{"chat" => %{"message" => "Draft the release notes."}})
47
    |> render_submit()
48
49
    assert has_element?(
50
             view,
51
             ~s([data-message-role="user"]),
52
             "Draft the release notes."
53
           )
54
55
    assert has_element?(view, ~s([role="status"]), "OpenRouter is not configured")
56
  end
57
58
  test "a non-operator on a live socket is still turned away by the mount hook", %{conn: conn} do
59
    # The plug gate runs on the initial document request; this drives the
60
    # LiveView mount directly so the on_mount hook is what answers.
61
    conn = log_in_chatting_user(conn, "socket-not-an-operator")
62
63
    assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/chat")
64
  end
65
66
  test "reasoning stays interleaved with successive tool attempts" do
67
    socket =
68
      %Phoenix.LiveView.Socket{}
69
      |> Phoenix.Component.assign(:stream_id, 42)
70
      |> Phoenix.Component.assign(:streaming?, true)
71
      |> Phoenix.Component.assign(:assistant_reasoning, nil)
72
      |> Phoenix.Component.assign(:assistant_tool_calls, [])
73
      |> Phoenix.Component.assign(:assistant_blocks, [])
74
75
    {:noreply, socket} =
76
      OpenAgentsWeb.ChatPlaceholderLive.handle_info(
77
        {:openrouter_stream_event, 42, {:reasoning_delta, "First attempt."}},
78
        socket
79
      )
80
81
    {:noreply, socket} =
82
      OpenAgentsWeb.ChatPlaceholderLive.handle_info(
83
        {:openrouter_stream_event, 42,
84
         {:tool_call_started,
85
          %{
86
            "call_id" => "call-1",
87
            "name" => "read_repository_file",
88
            "arguments" => ~s({"path":"null"})
89
          }}},
90
        socket
91
      )
92
93
    {:noreply, socket} =
94
      OpenAgentsWeb.ChatPlaceholderLive.handle_info(
95
        {:openrouter_stream_event, 42,
96
         {:tool_call_failed, %{"call_id" => "call-1", "error" => "Not found"}}},
97
        socket
98
      )
99
100
    {:noreply, socket} =
101
      OpenAgentsWeb.ChatPlaceholderLive.handle_info(
102
        {:openrouter_stream_event, 42, {:reasoning_delta, "Second attempt."}},
103
        socket
104
      )
105
106
    {:noreply, socket} =
107
      OpenAgentsWeb.ChatPlaceholderLive.handle_info(
108
        {:openrouter_stream_event, 42,
109
         {:tool_call_started,
110
          %{
111
            "call_id" => "call-2",
112
            "name" => "read_repository_file",
113
            "arguments" => ~s({"path":"README.md"})
114
          }}},
115
        socket
116
      )
117
118
    {:noreply, socket} =
119
      OpenAgentsWeb.ChatPlaceholderLive.handle_info(
120
        {:openrouter_stream_event, 42,
121
         {:tool_call_failed, %{"call_id" => "call-2", "error" => "Still not found"}}},
122
        socket
123
      )
124
125
    {:noreply, socket} =
126
      OpenAgentsWeb.ChatPlaceholderLive.handle_info(
127
        {:openrouter_stream_event, 42, {:reasoning_delta, "Report the failure."}},
128
        socket
129
      )
130
131
    assert [first_reasoning, first_tool, second_reasoning, second_tool, final_reasoning] =
132
             socket.assigns.assistant_blocks
133
134
    assert first_reasoning.type == :reasoning
135
    assert first_reasoning.text == "First attempt."
136
    assert is_integer(first_reasoning.duration)
137
    assert first_tool.type == :tool
138
    assert first_tool.tool_call.state == "output-error"
139
    assert first_tool.tool_call.error == "Not found"
140
    assert second_reasoning.type == :reasoning
141
    assert second_reasoning.text == "Second attempt."
142
    assert is_integer(second_reasoning.duration)
143
    assert second_tool.type == :tool
144
    assert second_tool.tool_call.state == "output-error"
145
    assert second_tool.tool_call.error == "Still not found"
146
    assert final_reasoning.type == :reasoning
147
    assert final_reasoning.text == "Report the failure."
148
    assert is_nil(final_reasoning.duration)
149
  end
150
151
  test "the browser renders durable tool workspace, duration, output, and receipts", %{conn: conn} do
152
    key = "placeholder-tool-metadata"
153
    user = github_user(key)
154
    conn = log_in_admin_user(conn, key)
155
156
    streamer = fn _request, callback, _options ->
157
      callback.(
158
        {:tool_call_started,
159
         %{
160
           "call_id" => "call-read",
161
           "name" => "read",
162
           "arguments" => ~s({"path":"README.md"})
163
         }}
164
      )
165
166
      callback.(
167
        {:tool_call_completed,
168
         %{
169
           "call_id" => "call-read",
170
           "output" => %{
171
             "schema" => "sarah.tool_outcome.v1",
172
             "status" => "succeeded",
173
             "result" => %{"content" => "OpenAgents"},
174
             "workspace" => %{
175
               "type" => "forge_worktree",
176
               "path" => "/private/var/lib/openagents/workspaces/repo"
177
             },
178
             "target_receipt_refs" => ["receipt:read:1"],
179
             "started_at" => "2026-08-22T19:43:28.000Z",
180
             "completed_at" => "2026-08-22T19:43:28.025Z"
181
           }
182
         }}
183
      )
184
185
      {:ok, %{"assistant_content" => "Read the file."}}
186
    end
187
188
    assert {:ok, %{"id" => run_id}} =
189
             OpenAgents.Chat.AccountTurns.submit(user, "Read the README.",
190
               subscriber: self(),
191
               streamer: streamer
192
             )
193
194
    assert_receive {:account_chat_completed, ^run_id, {:ok, _completion}}
195
    {:ok, view, _html} = live(conn, ~p"/chat")
196
197
    block_id = "chat-placeholder-block-#{run_id}-0"
198
    assert has_element?(view, "##{block_id}-metadata", "succeeded")
199
    assert has_element?(view, "##{block_id}-metadata", "repo")
200
    refute render(view) =~ "/private/var/lib/openagents"
201
    assert has_element?(view, "##{block_id}-metadata", "25 ms")
202
    assert has_element?(view, "##{block_id}-output", "OpenAgents")
203
    assert has_element?(view, "##{block_id}-receipts", "receipt:read:1")
204
  end
205
206
  test "streaming typed tool failures retain status and error code" do
207
    socket =
208
      %Phoenix.LiveView.Socket{}
209
      |> Phoenix.Component.assign(:stream_id, 84)
210
      |> Phoenix.Component.assign(:streaming?, true)
211
      |> Phoenix.Component.assign(:assistant_tool_calls, [])
212
      |> Phoenix.Component.assign(:assistant_blocks, [])
213
214
    {:noreply, socket} =
215
      OpenAgentsWeb.ChatPlaceholderLive.handle_info(
216
        {:openrouter_stream_event, 84,
217
         {:tool_call_started, %{"call_id" => "call-edit", "name" => "edit", "arguments" => "{}"}}},
218
        socket
219
      )
220
221
    {:noreply, socket} =
222
      OpenAgentsWeb.ChatPlaceholderLive.handle_info(
223
        {:openrouter_stream_event, 84,
224
         {:tool_call_failed,
225
          %{
226
            "call_id" => "call-edit",
227
            "output" => %{
228
              "schema" => "sarah.tool_outcome.v1",
229
              "status" => "failed",
230
              "error" => %{
231
                "code" => "workspace_read_only",
232
                "message" => "The workspace is read-only."
233
              },
234
              "workspace" => %{"path" => "/private/var/lib/openagents/workspaces/repo"}
235
            }
236
          }}},
237
        socket
238
      )
239
240
    assert [%{tool_call: tool}] = socket.assigns.assistant_blocks
241
    assert tool.status == "failed"
242
    assert tool.state == "output-error"
243
    assert tool.error_code == "workspace_read_only"
244
    assert tool.error == "The workspace is read-only."
245
    assert tool.workspace_label == "repo"
246
  end
247
end

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