Add forum JSON API under /api/v3 and CLI docs

e358675bf399 · AtlantisPleb · 2026-08-22T10:14:05-05:00 · parent 17f19edb2514

Add forum JSON API under /api/v3 and CLI docs

Boards, topics, thread reads are public; posting, replying, and legacy
identity claims require a forge:write token. Documents the new forum
commands for the Effect CLI.

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 92 · 2026-08-22T15:14:07.592270Z

Changed files

  • modified docs/openagents-cli/api.md
  • modified docs/openagents-cli/command-reference.md
  • modified lib/openagents/repositories/namespace.ex
  • modified lib/openagents_web/api_route_authority.ex
  • modified lib/openagents_web/components/layouts.ex
  • modified lib/openagents_web/controllers/auth_controller.ex
  • modified lib/openagents_web/controllers/data_controller.ex
  • added lib/openagents_web/controllers/forum_api_controller.ex
  • added lib/openagents_web/controllers/forum_api_json.ex
  • added lib/openagents_web/live/chat_placeholder_live.ex
  • modified lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • modified priv/docs/cli-api.md
  • modified priv/docs/cli-command-reference.md
  • modified test/openagents_web/account_chrome_test.exs
  • modified test/openagents_web/auth_controller_test.exs
  • modified test/openagents_web/controllers/atif_export_controller_test.exs
  • modified test/openagents_web/controllers/computer_agent_jobs_controller_test.exs
  • modified test/openagents_web/controllers/data_controller_test.exs
  • added test/openagents_web/controllers/forum_api_controller_test.exs
  • modified test/openagents_web/icon_affordances_test.exs
  • modified test/openagents_web/live/agent_surfaces_test.exs
  • modified test/openagents_web/live/changelog_live_test.exs
  • modified test/openagents_web/live/chat_delegation_rail_test.exs
  • modified test/openagents_web/live/chat_live_test.exs
  • added test/openagents_web/live/chat_placeholder_test.exs
  • modified test/openagents_web/live/chat_sidebar_sections_test.exs
  • modified test/openagents_web/live/computers_navigation_test.exs
  • modified test/openagents_web/live/leaderboard_live_test.exs

Diff

29 files changed, +789 -74

docs/openagents-cli/api.md modified +21

@@ -185,6 +185,27 @@ an API failure.

185 185
Do not parse human output from named repository commands as JSON. Add `--json`
186 186
to those commands. `openagents api` always returns the response body as JSON.
187 187
188
## Forum endpoints
189
190
The forum surface lives under `/api/v3/forum`. Reads are public; writes need
191
a `forge:write` API token and attribute posts to the token's account.
192
193
| Method | Path | Description |
194
| --- | --- | --- |
195
| `GET` | `/forum` | List public boards |
196
| `GET` | `/forum/topics?forum=SLUG&page=N` | One page of a board's topics |
197
| `GET` | `/forum/topics/:id?page=N` | Read a topic with its posts |
198
| `POST` | `/forum/topics` | Create a topic: `forum`, `title`, `body_text` |
199
| `POST` | `/forum/topics/:id/posts` | Reply: `body_text` |
200
| `POST` | `/forum/claims` | Claim a legacy identity: `actor_ref` |
201
| `GET` | `/forum/claims` | List the caller's identity claims |
202
203
```sh
204
openagents api "forum/topics?forum=general"
205
printf '%s' '{"forum":"general","title":"Hello","body_text":"First post"}' |
206
  openagents api -X POST --input - forum/topics
207
```
208
188 209
## Related documentation
189 210
190 211
- [CLI command reference](command-reference.md)
docs/openagents-cli/command-reference.md modified +33

@@ -133,6 +133,39 @@ history, issues, projects, and import records. You must pass `--yes`. When you

133 133
omit the repository, the CLI infers it from an exact OpenAgents `origin`
134 134
remote on the selected API origin.
135 135
136
## Forum commands
137
138
Read and write the forum from the command line. Posting and claiming
139
identities use the same credential as `repo` commands.
140
141
```sh
142
# List boards
143
openagents forum boards
144
145
# List topics in a board
146
openagents forum topics --board general
147
148
# Read a topic (a topic URL works too)
149
openagents forum topic <topic-id>
150
151
# Create a topic (--board defaults to general)
152
openagents forum post --title "Hello" --body "First post"
153
154
# Reply to a topic
155
openagents forum reply <topic-id> --body "My reply"
156
```
157
158
## Claim a legacy forum identity
159
160
If you posted on the previous forum, claim that identity so its history
161
attributes to your account. Claims are reviewed by an operator before they
162
link.
163
164
```sh
165
openagents forum claim agent:user_ed8297d8-1279-4b43-a1e7-f7867da19e20
166
openagents forum claims   # check review status
167
```
168
136 169
## API command
137 170
138 171
```text
lib/openagents/repositories/namespace.ex modified +1 -1

@@ -9,7 +9,7 @@ defmodule OpenAgents.Repositories.Namespace do

9 9
  @timestamps_opts [type: :utc_datetime_usec]
10 10
  @reserved_slugs ~w(
11 11
    admin api assets auth changelog chat components computers controller data dev device docs git og
12
    health healthz leaderboard machines memory repositories settings status voice
12
    health healthz leaderboard machines memory repositories sarah settings status voice
13 13
  )
14 14
15 15
  def reserved_slugs, do: @reserved_slugs
lib/openagents_web/api_route_authority.ex modified +8

@@ -53,6 +53,9 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

53 53
      "post /api/v3/device/authorizations" => :anonymous,
54 54
      "post /api/v3/device/authorizations/token" => :anonymous,
55 55
      # pipe_through :optional_forge_api — public reads, bearer-widened.
56
      "get /api/v3/forum" => :optional_bearer,
57
      "get /api/v3/forum/topics" => :optional_bearer,
58
      "get /api/v3/forum/topics/:id" => :optional_bearer,
56 59
      "get /api/v3/repos/:owner/:repo" => :optional_bearer,
57 60
      "get /api/v3/repos/:owner/:repo/issues" => :optional_bearer,
58 61
      "get /api/v3/repos/:owner/:repo/issues/:issue_number" => :optional_bearer,

@@ -79,6 +82,11 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

79 82
      "post /api/v3/orgs/:org/repos" => :required_bearer,
80 83
      "post /api/v3/orgs/:org/repos/imports" => :required_bearer,
81 84
      "post /api/v3/repos/:owner/:repo/issues" => :required_bearer,
85
      # pipe_through :forge_write_api — forum writes and identity claims.
86
      "post /api/v3/forum/topics" => :required_bearer,
87
      "post /api/v3/forum/topics/:topic_id/posts" => :required_bearer,
88
      "post /api/v3/forum/claims" => :required_bearer,
89
      "get /api/v3/forum/claims" => :required_bearer,
82 90
      "post /api/v3/repos/:owner/:repo/issues/:issue_number/assignees" => :required_bearer,
83 91
      "post /api/v3/repos/:owner/:repo/issues/:issue_number/comments" => :required_bearer,
84 92
      "post /api/v3/repos/:owner/:repo/issues/:issue_number/labels" => :required_bearer,
lib/openagents_web/components/layouts.ex modified +12 -2

@@ -711,7 +711,10 @@ defmodule OpenAgentsWeb.Layouts do

711 711
  slot :extra, doc: "rows contributed by the current page"
712 712
713 713
  defp sidebar(assigns) do
714
    assigns = assign(assigns, :agent_surfaces?, agent_surfaces?(assigns[:current_scope]))
714
    assigns =
715
      assigns
716
      |> assign(:agent_surfaces?, agent_surfaces?(assigns[:current_scope]))
717
      |> assign(:operator?, admin?(assigns[:current_scope]))
715 718
716 719
    ~H"""
717 720
    <aside id="sidebar" class="sidebar" aria-hidden="true">

@@ -719,6 +722,13 @@ defmodule OpenAgentsWeb.Layouts do

719 722
720 723
      <nav class="sidebar-nav" aria-label="OpenAgents surfaces">
721 724
        <Layouts.sidebar_link path={~p"/"} label="Home" icon="home" patchable={false} />
725
        <Layouts.sidebar_link
726
          :if={@operator?}
727
          path={~p"/chat"}
728
          label="Chat"
729
          icon="chats"
730
          patchable={false}
731
        />
722 732
        <Layouts.sidebar_link
723 733
          path={~p"/repositories"}
724 734
          label="Repositories"

@@ -754,7 +764,7 @@ defmodule OpenAgentsWeb.Layouts do

754 764
        open
755 765
        state={@sidebar_sections}
756 766
      >
757
        <Layouts.sidebar_link path={~p"/chat"} label="Chat" icon="chat" patchable={false} />
767
        <Layouts.sidebar_link path={~p"/sarah"} label="Chat" icon="chat" patchable={false} />
758 768
        <Layouts.sidebar_link
759 769
          path={~p"/computers"}
760 770
          label="Computers"
lib/openagents_web/controllers/auth_controller.ex modified +3 -3

@@ -44,7 +44,7 @@ defmodule OpenAgentsWeb.AuthController do

44 44
      |> put_session("user_id", active_user.id)
45 45
      |> put_session(@identity_session_key, identity(active_user))
46 46
      |> put_resp_header("cache-control", "no-store")
47
      |> redirect(to: ~p"/chat")
47
      |> redirect(to: ~p"/sarah")
48 48
    else
49 49
      {:error, :banned} -> auth_failure(conn, "banned")
50 50
      {:error, _reason} -> auth_failure(conn, "failed")

@@ -83,13 +83,13 @@ defmodule OpenAgentsWeb.AuthController do

83 83
        conn
84 84
        |> put_flash(:info, "GitHub tools disconnected and the retained grant was revoked.")
85 85
        |> put_resp_header("cache-control", "no-store")
86
        |> redirect(to: ~p"/chat")
86
        |> redirect(to: ~p"/sarah")
87 87
88 88
      {:error, _reason} ->
89 89
        conn
90 90
        |> put_flash(:error, "GitHub tools could not be disconnected. Try again.")
91 91
        |> put_resp_header("cache-control", "no-store")
92
        |> redirect(to: ~p"/chat")
92
        |> redirect(to: ~p"/sarah")
93 93
    end
94 94
  end
95 95
lib/openagents_web/controllers/data_controller.ex modified +2 -2

@@ -43,7 +43,7 @@ defmodule OpenAgentsWeb.DataController do

43 43
    with {:ok, user, owner, conversation} <- scope(conn),
44 44
         {:ok, :deleted} <- DataRights.delete(user, owner, conversation) do
45 45
      conn
46
      |> redirect(to: ~p"/chat")
46
      |> redirect(to: ~p"/sarah")
47 47
    else
48 48
      {:error, :text_turn_in_progress} ->
49 49
        conn |> put_status(:conflict) |> text("Stop Sarah's typed response before deletion.")

@@ -66,7 +66,7 @@ defmodule OpenAgentsWeb.DataController do

66 66
    if DataRights.reset_enabled?() do
67 67
      with {:ok, user, owner, conversation} <- scope(conn),
68 68
           {:ok, :deleted} <- DataRights.delete(user, owner, conversation) do
69
        redirect(conn, to: ~p"/chat")
69
        redirect(conn, to: ~p"/sarah")
70 70
      else
71 71
        {:error, :text_turn_in_progress} ->
72 72
          conn |> put_status(:conflict) |> text("Stop Sarah's typed response before the reset.")
lib/openagents_web/controllers/forum_api_controller.ex added +201

@@ -0,0 +1,201 @@

1
defmodule OpenAgentsWeb.ForumApiController do
2
  @moduledoc """
3
  The forum surface of the `/api/v3` JSON API: boards, topics, posts, and
4
  legacy identity claims.
5
6
  Reads are public. Writes require a `forge:write` API token and attribute
7
  posts to the token's account.
8
  """
9
10
  use OpenAgentsWeb, :controller
11
12
  alias OpenAgents.Forum
13
14
  # Errors arrive as {:error, :not_found}, {:error, :missing_forum}, or
15
  # {:error, field, message}; each `with/else` maps them onto a response.
16
17
  def boards(conn, _params) do
18
    render(conn, :boards, forums: Forum.list_public_forums())
19
  end
20
21
  def topics(conn, params) do
22
    case fetch_forum(params) do
23
      {:ok, forum} ->
24
        {topics, total} = forum_topics_page(forum, params)
25
26
        render(conn, :topics,
27
          topics: topics,
28
          forum: forum,
29
          pagination: %{
30
            page: Forum.parse_page(params["page"]),
31
            per_page: Forum.topics_per_page(),
32
            total: total
33
          }
34
        )
35
36
      _missing ->
37
        not_found(conn)
38
    end
39
  end
40
41
  def show_topic(conn, %{"id" => id} = params) do
42
    topic = Forum.get_topic!(id)
43
    posts = Forum.list_posts(topic, page: params["page"])
44
45
    render(conn, :topic,
46
      topic: topic,
47
      posts: posts,
48
      pagination: %{
49
        page: Forum.parse_page(params["page"]),
50
        per_page: Forum.posts_per_page(),
51
        total: Forum.count_posts(topic)
52
      }
53
    )
54
  rescue
55
    Ecto.NoResultsError -> not_found(conn)
56
  end
57
58
  def create_topic(conn, %{"forum" => slug, "title" => title, "body_text" => body_text} = params) do
59
    cond do
60
      not valid_text?(title) ->
61
        unprocessable(conn, :title)
62
63
      not valid_text?(body_text) ->
64
        unprocessable(conn, :body_text)
65
66
      true ->
67
        case fetch_forum(%{"forum" => slug}) do
68
          {:ok, forum} ->
69
            case Forum.create_topic(forum, topic_attrs(conn, params)) do
70
              {:ok, topic} ->
71
                conn
72
                |> put_status(:created)
73
                |> render(:topic,
74
                  topic: topic,
75
                  posts: [first_post(topic)],
76
                  pagination: %{
77
                    page: 1,
78
                    per_page: Forum.posts_per_page(),
79
                    total: 1
80
                  }
81
                )
82
83
              {:error, %Ecto.Changeset{} = changeset} ->
84
                conn |> put_status(:unprocessable_entity) |> render(:error, changeset: changeset)
85
86
              _other ->
87
                conn |> put_status(:conflict) |> json(%{error: "topic_closed"})
88
            end
89
90
          _missing ->
91
            not_found(conn)
92
        end
93
    end
94
  end
95
96
  def create_post(conn, %{"topic_id" => topic_id, "body_text" => body_text} = params) do
97
    if valid_text?(body_text) do
98
      topic = Forum.get_topic!(topic_id)
99
100
      attrs =
101
        actor_attrs(conn)
102
        |> Map.merge(%{
103
          body_text: body_text,
104
          idempotency_key: Map.get(params, "idempotency_key") || Ecto.UUID.generate()
105
        })
106
107
      case Forum.create_post(topic, attrs) do
108
        {:ok, post} ->
109
          conn |> put_status(:created) |> render(:post, post: post)
110
111
        _closed ->
112
          conn |> put_status(:conflict) |> json(%{error: "topic_closed"})
113
      end
114
    else
115
      unprocessable(conn, :body_text)
116
    end
117
  rescue
118
    Ecto.NoResultsError -> not_found(conn)
119
  end
120
121
  def create_claim(conn, %{"actor_ref" => actor_ref}) when is_binary(actor_ref) do
122
    case Forum.start_actor_link(conn.assigns.current_user, String.trim(actor_ref), "api_token") do
123
      {:ok, link} ->
124
        conn |> put_status(:created) |> render(:claim, claim: link)
125
126
      {:error, changeset} ->
127
        conn |> put_status(:unprocessable_entity) |> render(:error, changeset: changeset)
128
    end
129
  end
130
131
  def list_claims(conn, _params) do
132
    render(conn, :claims, claims: Forum.list_actor_links(conn.assigns.current_user))
133
  end
134
135
  ## Helpers
136
137
  defp first_post(topic) do
138
    case Forum.list_posts(topic) do
139
      [post | _] -> post
140
      [] -> nil
141
    end
142
  end
143
144
  defp forum_topics_page(forum, params) do
145
    page = Forum.parse_page(params["page"])
146
    topics = Forum.list_topics(forum, page: page)
147
    total = Forum.count_topics(forum)
148
    {topics, total}
149
  end
150
151
  defp fetch_forum(%{"forum" => slug}) when is_binary(slug) do
152
    case Forum.get_forum_by_slug(slug) do
153
      nil -> {:error, :not_found}
154
      forum -> {:ok, forum}
155
    end
156
  end
157
158
  defp fetch_forum(_params), do: {:error, :missing_forum}
159
160
  defp topic_attrs(conn, params) do
161
    actor_attrs(conn)
162
    |> Map.merge(%{
163
      title: params["title"],
164
      slug: slugify(params["title"]),
165
      body_text: params["body_text"],
166
      idempotency_key: Map.get(params, "idempotency_key") || Ecto.UUID.generate()
167
    })
168
  end
169
170
  defp actor_attrs(conn) do
171
    user = conn.assigns.current_user
172
173
    %{
174
      actor_ref: "user:#{user.id}",
175
      actor_display_name: user.github_name || user.github_login,
176
      actor_slug: user.github_login,
177
      actor_is_agent: false
178
    }
179
  end
180
181
  defp slugify(nil), do: nil
182
183
  defp slugify(title) do
184
    title
185
    |> String.downcase()
186
    |> String.replace(~r/[^a-z0-9]+/, "-")
187
    |> String.trim("-")
188
    |> String.slice(0, 80)
189
  end
190
191
  defp valid_text?(value) when is_binary(value) and byte_size(value) > 0, do: true
192
  defp valid_text?(_), do: false
193
194
  defp unprocessable(conn, field),
195
    do:
196
      conn
197
      |> put_status(:unprocessable_entity)
198
      |> json(%{errors: %{field => ["must be a non-empty string"]}})
199
200
  defp not_found(conn), do: conn |> put_status(:not_found) |> json(%{error: "not_found"})
201
end
lib/openagents_web/controllers/forum_api_json.ex added +126

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

1
defmodule OpenAgentsWeb.ForumApiJSON do
2
  @moduledoc "Renders forum JSON for the `/api/v3` surface."
3
4
  alias OpenAgents.Forum.ActorLink
5
6
  def render("boards.json", %{forums: forums}) do
7
    %{boards: Enum.map(forums, &board_json/1)}
8
  end
9
10
  def render("topics.json", %{topics: topics, forum: forum, pagination: pagination}) do
11
    %{
12
      board: board_json(forum),
13
      topics: Enum.map(topics, &topic_json/1),
14
      pagination: %{
15
        page: pagination.page,
16
        per_page: pagination.per_page,
17
        total: pagination.total,
18
        total_pages: total_pages(pagination.total, pagination.per_page)
19
      }
20
    }
21
  end
22
23
  def render("topic.json", %{topic: topic, posts: posts, pagination: pagination}) do
24
    %{
25
      topic: topic_json(topic) |> Map.merge(%{posts_count: pagination.total}),
26
      posts: Enum.map(posts, &post_json/1),
27
      pagination: %{
28
        page: pagination.page,
29
        per_page: pagination.per_page,
30
        total: pagination.total,
31
        total_pages: total_pages(pagination.total, pagination.per_page)
32
      }
33
    }
34
  end
35
36
  def render("post.json", %{post: post}) do
37
    %{post: post_json(post)}
38
  end
39
40
  def render("claim.json", %{claim: claim}) do
41
    %{claim: claim_json(claim)}
42
  end
43
44
  def render("claims.json", %{claims: claims}) do
45
    %{claims: Enum.map(claims, &claim_json/1)}
46
  end
47
48
  def render("error.json", %{changeset: changeset}) do
49
    %{errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)}
50
  end
51
52
  defp board_json(forum) do
53
    %{
54
      id: forum.id,
55
      slug: forum.slug,
56
      title: forum.title,
57
      description: forum.description,
58
      topic_count: forum.topic_count,
59
      post_count: forum.post_count,
60
      url: "https://openagents.com/forum/f/#{forum.slug}"
61
    }
62
  end
63
64
  defp topic_json(topic) do
65
    %{
66
      id: topic.id,
67
      title: topic.title,
68
      slug: topic.slug,
69
      state: topic.state,
70
      pinned: topic.pin_state == "pinned",
71
      posts_count: topic.post_count,
72
      actor_ref: topic.actor_ref,
73
      author: %{
74
        ref: topic.actor_ref,
75
        display_name: topic.actor_display_name,
76
        is_agent: topic.actor_is_agent
77
      },
78
      created_at: iso(topic.created_at),
79
      updated_at: iso(topic.updated_at),
80
      url: "https://openagents.com/forum/t/#{topic.id}"
81
    }
82
  end
83
84
  defp post_json(nil), do: nil
85
86
  defp post_json(post) do
87
    %{
88
      id: post.id,
89
      topic_id: post.topic_id,
90
      post_number: post.post_number,
91
      body_text: post.body_text,
92
      state: post.state,
93
      author: %{
94
        ref: post.actor_ref,
95
        display_name: post.actor_display_name,
96
        is_agent: post.actor_is_agent
97
      },
98
      created_at: iso(post.created_at),
99
      url: "https://openagents.com/forum/t/#{post.topic_id}"
100
    }
101
  end
102
103
  defp claim_json(%ActorLink{} = link) do
104
    %{
105
      id: link.id,
106
      actor_ref: link.actor_ref,
107
      status: link.status,
108
      proof_method: link.proof_method,
109
      linked_at: iso(link.linked_at),
110
      created_at: iso(link.inserted_at)
111
    }
112
  end
113
114
  defp iso(nil), do: nil
115
116
  defp iso(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
117
118
  defp iso(_other), do: nil
119
120
  defp total_pages(0, _per_page), do: 1
121
122
  defp total_pages(total, per_page), do: ceil(total / per_page)
123
124
  defp translate_error({msg, opts}),
125
    do: String.replace(msg, "%{count}", to_string(opts[:count] || ""))
126
end
lib/openagents_web/live/chat_placeholder_live.ex added +40

@@ -0,0 +1,40 @@

1
defmodule OpenAgentsWeb.ChatPlaceholderLive do
2
  @moduledoc """
3
  A placeholder for the upcoming chat surface, reachable at `/chat` by
4
  operators only.
5
6
  The real product has not landed; this page exists so the route, its
7
  authority gate, and its sidebar row are in place before the surface is.
8
  """
9
10
  use OpenAgentsWeb, :live_view
11
12
  @impl true
13
  def mount(_params, _session, socket) do
14
    {:ok, assign(socket, :page_title, "Chat")}
15
  end
16
17
  @impl true
18
  def render(assigns) do
19
    ~H"""
20
    <Layouts.app
21
      flash={@flash}
22
      sidebar_sections={assigns[:sidebar_sections]}
23
      current_scope={@current_scope}
24
      title="Chat"
25
    >
26
      <div id="chat-placeholder">
27
        <.empty id="chat-placeholder-empty" title="Chat is being built">
28
          <p>
29
            This will become OpenAgents' new chat surface. For now it is an
30
            operator preview with nothing to operate on.
31
          </p>
32
          <p>
33
            The Sarah conversation keeps its home at <.link navigate={~p"/sarah"} class="underline">/sarah</.link>.
34
          </p>
35
        </.empty>
36
      </div>
37
    </Layouts.app>
38
    """
39
  end
40
end
lib/openagents_web/route_authority.ex modified +5 -2

@@ -32,7 +32,7 @@ defmodule OpenAgentsWeb.RouteAuthority do

32 32
  ]
33 33
34 34
  @authenticated_browser_prefixes [
35
    "/chat",
35
    "/sarah",
36 36
    "/computers",
37 37
    "/voice/",
38 38
    "/data",

@@ -129,6 +129,9 @@ defmodule OpenAgentsWeb.RouteAuthority do

129 129
  defp policy(%{path: "/logout"}),
130 130
    do: declaration(:authenticated_browser, "encrypted browser session", "session:delete", true)
131 131
132
  defp policy(%{path: "/chat"}),
133
    do: declaration(:operator, "configured operator GitHub ID", "chat:preview", false)
134
132 135
  defp policy(%{path: "/admin/analytics"}),
133 136
    do: declaration(:operator, "configured operator GitHub ID", "analytics:read", false)
134 137

@@ -289,7 +292,7 @@ defmodule OpenAgentsWeb.RouteAuthority do

289 292
  defp browser_scope(_path), do: "product:self"
290 293
291 294
  defp browser_mutation?(path, :get),
292
    do: path in ["/chat", "/memory", "/computers", "/settings/api-tokens"]
295
    do: path in ["/sarah", "/memory", "/computers", "/settings/api-tokens"]
293 296
294 297
  defp browser_mutation?(_path, _verb), do: true
295 298
lib/openagents_web/router.ex modified +33 -1

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

103 103
104 104
    live_session :authenticated,
105 105
      on_mount: [{OpenAgentsWeb.UserAuth, :ensure_authenticated}] do
106
      live "/chat", ChatLive, :index
106
      live "/sarah", ChatLive, :index
107 107
      live "/memory", MemoryLive, :index
108 108
      live "/computers", ComputersLive, :index
109 109
      live "/settings/api-tokens", ApiTokensLive, :index

@@ -155,6 +155,23 @@ defmodule OpenAgentsWeb.Router do

155 155
    delete "/github/connection", AuthController, :disconnect
156 156
  end
157 157
158
  # The chat surface is a placeholder for the upcoming product, and it is
159
  # operator-only: the gate runs twice, as a plug on the initial request and as
160
  # an on_mount hook on the LiveView, the same belt-and-suspenders the /admin
161
  # scope uses. Everyone else is redirected to home rather than shown a login
162
  # wall for a page they cannot open.
163
  scope "/", OpenAgentsWeb do
164
    pipe_through [:browser, :authenticated, :operator]
165
166
    live_session :operator_chat,
167
      on_mount: [
168
        {OpenAgentsWeb.UserAuth, :ensure_authenticated},
169
        {OpenAgentsWeb.UserAuth, :ensure_admin}
170
      ] do
171
      live "/chat", ChatPlaceholderLive, :index
172
    end
173
  end
174
158 175
  # Reading issues is a public activity on a public repository, the way code
159 176
  # browsing already is, so this session runs behind plain :browser and mounts
160 177
  # whoever is signed in. Each view decides what an anonymous visitor may do,

@@ -252,6 +269,16 @@ defmodule OpenAgentsWeb.Router do

252 269
    get "/repos/:owner/:repo/assignees/:assignee", AssigneeController, :show
253 270
  end
254 271
272
  scope "/api/v3", OpenAgentsWeb do
273
    pipe_through :forge_write_api
274
275
    # Forum writes. Reads are public and live in the optional-auth scope.
276
    post "/forum/topics", ForumApiController, :create_topic
277
    post "/forum/topics/:topic_id/posts", ForumApiController, :create_post
278
    post "/forum/claims", ForumApiController, :create_claim
279
    get "/forum/claims", ForumApiController, :list_claims
280
  end
281
255 282
  scope "/api/v3", OpenAgentsWeb do
256 283
    pipe_through :optional_forge_api
257 284

@@ -262,6 +289,11 @@ defmodule OpenAgentsWeb.Router do

262 289
    get "/repos/:owner/:repo/projectsV2/:project_number", ProjectController, :show
263 290
    get "/repos/:owner/:repo/projectsV2/:project_number/items", ProjectController, :items
264 291
    get "/repos/:owner/:repo/projectsV2/:project_number/fields", ProjectController, :fields
292
293
    # The forum reads. Posting and claiming live behind the write scope.
294
    get "/forum", ForumApiController, :boards
295
    get "/forum/topics", ForumApiController, :topics
296
    get "/forum/topics/:id", ForumApiController, :show_topic
265 297
  end
266 298
267 299
  scope "/api/v3", OpenAgentsWeb do
priv/docs/cli-api.md modified +21

@@ -136,6 +136,27 @@ openagents --json api repos/OWNER/REPOSITORY/issues >issues.json

136 136
137 137
Preserve the request ID when you report a failed API call.
138 138
139
## Forum endpoints
140
141
The forum surface lives under `/api/v3/forum`. Reads are public; writes need
142
a `forge:write` API token and attribute posts to the token's account.
143
144
| Method | Path | Description |
145
| --- | --- | --- |
146
| `GET` | `/forum` | List public boards |
147
| `GET` | `/forum/topics?forum=SLUG&page=N` | One page of a board's topics |
148
| `GET` | `/forum/topics/:id?page=N` | Read a topic with its posts |
149
| `POST` | `/forum/topics` | Create a topic: `forum`, `title`, `body_text` |
150
| `POST` | `/forum/topics/:id/posts` | Reply: `body_text` |
151
| `POST` | `/forum/claims` | Claim a legacy identity: `actor_ref` |
152
| `GET` | `/forum/claims` | List the caller's identity claims |
153
154
```sh
155
openagents api "forum/topics?forum=general"
156
printf '%s' '{"forum":"general","title":"Hello","body_text":"First post"}' |
157
  openagents api -X POST --input - forum/topics
158
```
159
139 160
## Related documentation
140 161
141 162
- [REST API](/docs/rest-api)
priv/docs/cli-command-reference.md modified +33

@@ -133,6 +133,39 @@ history, issues, projects, and import records. You must pass `--yes`. When you

133 133
omit the repository, the CLI infers it from an exact OpenAgents `origin`
134 134
remote on the selected API origin.
135 135
136
## Forum commands
137
138
Read and write the forum from the command line. Posting and claiming
139
identities use the same credential as `repo` commands.
140
141
```sh
142
# List boards
143
openagents forum boards
144
145
# List topics in a board
146
openagents forum topics --board general
147
148
# Read a topic (a topic URL works too)
149
openagents forum topic <topic-id>
150
151
# Create a topic (--board defaults to general)
152
openagents forum post --title "Hello" --body "First post"
153
154
# Reply to a topic
155
openagents forum reply <topic-id> --body "My reply"
156
```
157
158
## Claim a legacy forum identity
159
160
If you posted on the previous forum, claim that identity so its history
161
attributes to your account. Claims are reviewed by an operator before they
162
link.
163
164
```sh
165
openagents forum claim agent:user_ed8297d8-1279-4b43-a1e7-f7867da19e20
166
openagents forum claims   # check review status
167
```
168
136 169
## Call an API route
137 170
138 171
```text
test/openagents_web/account_chrome_test.exs modified +4 -4

@@ -28,7 +28,7 @@ defmodule OpenAgentsWeb.AccountChromeTest do

28 28
        })
29 29
30 30
      assert user.github_name == "Ada Lovelace"
31
      {:ok, _view, html} = live(conn, ~p"/chat")
31
      {:ok, _view, html} = live(conn, ~p"/sarah")
32 32
33 33
      assert html =~ "Ada Lovelace"
34 34
      assert html =~ "@#{user.github_login}"

@@ -40,7 +40,7 @@ defmodule OpenAgentsWeb.AccountChromeTest do

40 40
      assert user.github_name == nil
41 41
42 42
      conn = log_in_github_user(conn, "nameless-account")
43
      {:ok, _view, html} = live(conn, ~p"/chat")
43
      {:ok, _view, html} = live(conn, ~p"/sarah")
44 44
45 45
      # The handle becomes the display name, and is not then repeated beneath
46 46
      # itself as a qualifier.

@@ -59,7 +59,7 @@ defmodule OpenAgentsWeb.AccountChromeTest do

59 59
  describe "connection chrome" do
60 60
    test "the conversation states no connection status at all", %{conn: conn} do
61 61
      conn = log_in_github_user(conn, "connection-user")
62
      {:ok, view, html} = live(conn, ~p"/chat")
62
      {:ok, view, html} = live(conn, ~p"/sarah")
63 63
64 64
      # Removed at the owner's direction. A bar that reads CONNECTED whenever
65 65
      # the page is working spends permanent space to say nothing: the only

@@ -74,7 +74,7 @@ defmodule OpenAgentsWeb.AccountChromeTest do

74 74
  describe "composer chrome" do
75 75
    test "drops the keyboard hint line", %{conn: conn} do
76 76
      conn = log_in_github_user(conn, "composer-hint-user")
77
      {:ok, _view, html} = live(conn, ~p"/chat")
77
      {:ok, _view, html} = live(conn, ~p"/sarah")
78 78
79 79
      refute html =~ "FOR A NEW LINE"
80 80
      refute html =~ "TO SEND"
test/openagents_web/auth_controller_test.exs modified +6 -6

@@ -31,7 +31,7 @@ defmodule OpenAgentsWeb.AuthControllerTest do

31 31
      |> recycle()
32 32
      |> get(~p"/auth/github/callback?code=valid-code&state=#{state}")
33 33
34
    assert redirected_to(authenticated) == ~p"/chat"
34
    assert redirected_to(authenticated) == ~p"/sarah"
35 35
    user_id = get_session(authenticated, "user_id")
36 36
    assert {:ok, user} = Accounts.get_active_user(user_id)
37 37
    assert user.github_id == 501

@@ -49,7 +49,7 @@ defmodule OpenAgentsWeb.AuthControllerTest do

49 49
    cookie = authenticated |> get_resp_header("set-cookie") |> Enum.join(";")
50 50
    refute cookie =~ "ephemeral-github-token"
51 51
52
    first_browser = authenticated |> recycle() |> get(~p"/chat")
52
    first_browser = authenticated |> recycle() |> get(~p"/sarah")
53 53
    # The brand mark is the application's, once, in the sidebar. Chat used to
54 54
    # render a second "SARAH" mark in a header belonging to its own rail.
55 55
    assert html_response(first_browser, 200) =~ "OpenAgents"

@@ -59,7 +59,7 @@ defmodule OpenAgentsWeb.AuthControllerTest do

59 59
      build_conn()
60 60
      |> init_test_session(%{"user_id" => user.id})
61 61
62
    assert {:ok, _view, _html} = live(second_browser, ~p"/chat")
62
    assert {:ok, _view, _html} = live(second_browser, ~p"/sarah")
63 63
    assert Conversations.get_conversation_for_user(user).id == conversation.id
64 64
65 65
    replay =

@@ -83,7 +83,7 @@ defmodule OpenAgentsWeb.AuthControllerTest do

83 83
      |> recycle()
84 84
      |> get(~p"/auth/github/callback?code=valid-code&state=#{state}")
85 85
86
    assert redirected_to(authenticated) == ~p"/chat"
86
    assert redirected_to(authenticated) == ~p"/sarah"
87 87
    assert {:ok, _user} = authenticated |> get_session("user_id") |> Accounts.get_active_user()
88 88
  end
89 89

@@ -120,7 +120,7 @@ defmodule OpenAgentsWeb.AuthControllerTest do

120 120
      |> init_test_session(%{"user_id" => user.id})
121 121
      |> delete(~p"/github/connection")
122 122
123
    assert redirected_to(disconnected) == ~p"/chat"
123
    assert redirected_to(disconnected) == ~p"/sarah"
124 124
    retained_identity = Accounts.get_user(user.id)
125 125
    assert retained_identity.github_token_ciphertext == nil
126 126
    assert retained_identity.github_token_key_id == nil

@@ -145,7 +145,7 @@ defmodule OpenAgentsWeb.AuthControllerTest do

145 145
      |> init_test_session(%{"user_id" => user.id})
146 146
      |> delete(~p"/github/connection")
147 147
148
    assert redirected_to(refused) == ~p"/chat"
148
    assert redirected_to(refused) == ~p"/sarah"
149 149
    assert get_resp_header(refused, "cache-control") == ["no-store"]
150 150
    retained = Accounts.get_user(user.id)
151 151
    assert retained.github_token_ciphertext == user.github_token_ciphertext
test/openagents_web/controllers/atif_export_controller_test.exs modified +1 -1

@@ -61,7 +61,7 @@ defmodule OpenAgentsWeb.AtifExportControllerTest do

61 61
62 62
  test "the chat command bar carries the export chip as a download link", %{conn: conn} do
63 63
    conn = log_in_github_user(conn, "atif-export-chip-browser")
64
    assert {:ok, view, _html} = live(conn, ~p"/chat")
64
    assert {:ok, view, _html} = live(conn, ~p"/sarah")
65 65
66 66
    assert has_element?(view, ~s(a#export-atif[href="/data/export/atif"][download]))
67 67
  end
test/openagents_web/controllers/computer_agent_jobs_controller_test.exs modified +1 -1

@@ -23,7 +23,7 @@ defmodule OpenAgentsWeb.ComputerAgentJobsControllerTest do

23 23
       end}
24 24
    )
25 25
26
    {:ok, view, _html} = live(browser_conn(conn, user), ~p"/chat")
26
    {:ok, view, _html} = live(browser_conn(conn, user), ~p"/sarah")
27 27
28 28
    response =
29 29
      build_conn()
test/openagents_web/controllers/data_controller_test.exs modified +2 -2

@@ -163,7 +163,7 @@ defmodule OpenAgentsWeb.DataControllerTest do

163 163
      |> log_in_github_user(token)
164 164
      |> delete_data("DELETE MY SARAH DATA")
165 165
166
    assert redirected_to(deleted) == ~p"/chat"
166
    assert redirected_to(deleted) == ~p"/sarah"
167 167
    assert get_session(deleted, "user_id") == user.id
168 168
169 169
    assert Repo.get(OpenAgents.Conversations.Visitor, owner.id) == nil

@@ -296,7 +296,7 @@ defmodule OpenAgentsWeb.DataControllerTest do

296 296
      |> log_in_github_user(token)
297 297
      |> reset_data()
298 298
299
    assert redirected_to(reset) == ~p"/chat"
299
    assert redirected_to(reset) == ~p"/sarah"
300 300
    assert get_session(reset, "user_id") == user.id
301 301
302 302
    assert Repo.get(OpenAgents.Conversations.Visitor, owner.id) == nil
test/openagents_web/controllers/forum_api_controller_test.exs added +130

@@ -0,0 +1,130 @@

1
defmodule OpenAgentsWeb.ForumApiControllerTest do
2
  use OpenAgentsWeb.ConnCase, async: false
3
4
  alias OpenAgents.Forum
5
  alias OpenAgents.Repo
6
7
  setup %{conn: conn} do
8
    {:ok, forum} =
9
      %Forum.Forum{}
10
      |> Forum.Forum.changeset(%{slug: "general", title: "General"})
11
      |> Repo.insert()
12
13
    {:ok, conn: conn, forum: forum}
14
  end
15
16
  defp topic(forum, attrs \\ %{}) do
17
    {:ok, topic} =
18
      Forum.create_topic(
19
        forum,
20
        Map.merge(
21
          %{
22
            title: "Hello world",
23
            slug: "hello-world",
24
            body_text: "First post body",
25
            idempotency_key: Ecto.UUID.generate(),
26
            actor_ref: "agent:user_ed8297d8-1279-4b43-a1e7-f7867da19e20",
27
            actor_display_name: "Orrery",
28
            actor_slug: "orrery"
29
          },
30
          attrs
31
        )
32
      )
33
34
    topic
35
  end
36
37
  test "GET /api/v3/forum lists public boards", %{conn: conn} do
38
    conn = get(conn, ~p"/api/v3/forum")
39
40
    assert %{"boards" => [board]} = json_response(conn, 200)
41
    assert board["slug"] == "general"
42
    assert board["title"] == "General"
43
  end
44
45
  test "GET /api/v3/forum/topics lists a board's topics", %{conn: conn, forum: forum} do
46
    topic(forum)
47
48
    conn = get(conn, ~p"/api/v3/forum/topics?forum=general")
49
50
    assert %{"topics" => [t], "pagination" => pagination} = json_response(conn, 200)
51
    assert t["title"] == "Hello world"
52
    assert t["url"] =~ "/forum/t/"
53
    assert pagination["total"] == 1
54
  end
55
56
  test "GET /api/v3/forum/topics/:id returns the thread with posts", %{conn: conn, forum: forum} do
57
    topic = topic(forum)
58
59
    conn = get(conn, ~p"/api/v3/forum/topics/#{topic.id}")
60
61
    assert %{"topic" => t, "posts" => posts} = json_response(conn, 200)
62
    assert t["title"] == "Hello world"
63
    assert [%{"body_text" => "First post body"}] = posts
64
  end
65
66
  test "POST /api/v3/forum/topics creates a topic attributed to the token account", %{conn: conn} do
67
    conn =
68
      conn
69
      |> put_forge_api_token("forum-api-create")
70
      |> post(~p"/api/v3/forum/topics", %{
71
        forum: "general",
72
        title: "From the CLI",
73
        body_text: "Posted over the API"
74
      })
75
76
    assert response = json_response(conn, 201)
77
    assert response["topic"]["title"] == "From the CLI"
78
    assert hd(response["posts"])["author"]["is_agent"] == false
79
  end
80
81
  test "POST /api/v3/forum/topics requires authentication", %{conn: conn} do
82
    conn = post(conn, ~p"/api/v3/forum/topics", %{forum: "general", title: "x", body_text: "y"})
83
84
    assert json_response(conn, 401)
85
  end
86
87
  test "POST /api/v3/forum/topics/:id/posts replies to a topic", %{conn: conn, forum: forum} do
88
    topic = topic(forum)
89
90
    conn =
91
      conn
92
      |> put_forge_api_token("forum-api-reply")
93
      |> post(~p"/api/v3/forum/topics/#{topic.id}/posts", %{body_text: "API reply"})
94
95
    assert %{"post" => post} = json_response(conn, 201)
96
    assert post["post_number"] == 2
97
    assert Forum.count_posts(topic) == 2
98
  end
99
100
  test "POST /api/v3/forum/claims starts an identity claim", %{conn: conn} do
101
    conn =
102
      conn
103
      |> put_forge_api_token("forum-api-claim")
104
      |> post(~p"/api/v3/forum/claims", %{
105
        actor_ref: "agent:user_ed8297d8-1279-4b43-a1e7-f7867da19e20"
106
      })
107
108
    assert %{"claim" => %{"status" => "pending"}} = json_response(conn, 201)
109
  end
110
111
  test "GET /api/v3/forum/claims lists the caller's claims", %{conn: conn} do
112
    authed = put_forge_api_token(conn, "forum-api-list-claims")
113
114
    {:ok, _} =
115
      Forum.start_actor_link(github_user("api-token-forum-api-list-claims"), "agent:user_1")
116
117
    _ = authed
118
119
    conn = get(authed, ~p"/api/v3/forum/claims")
120
121
    assert %{"claims" => claims} = json_response(conn, 200)
122
    assert length(claims) >= 1
123
  end
124
125
  test "unknown board is 404", %{conn: conn} do
126
    conn = get(conn, ~p"/api/v3/forum/topics?forum=nope")
127
128
    assert json_response(conn, 404)
129
  end
130
end
test/openagents_web/icon_affordances_test.exs modified +3 -3

@@ -14,7 +14,7 @@ defmodule OpenAgentsWeb.IconAffordancesTest do

14 14
  describe "icon-only controls" do
15 15
    test "the composer send control is named even though it shows only a glyph", %{conn: conn} do
16 16
      conn = log_in_github_user(conn, "icon-send-user")
17
      {:ok, view, html} = live(conn, ~p"/chat")
17
      {:ok, view, html} = live(conn, ~p"/sarah")
18 18
19 19
      assert has_element?(view, ~s(#send-message[aria-label="Send"]))
20 20

@@ -31,7 +31,7 @@ defmodule OpenAgentsWeb.IconAffordancesTest do

31 31
32 32
    test "notice dismissal keeps its name after losing the word CLOSE", %{conn: conn} do
33 33
      conn = log_in_github_user(conn, "icon-close-user")
34
      {:ok, view, _html} = live(conn, ~p"/chat")
34
      {:ok, view, _html} = live(conn, ~p"/sarah")
35 35
36 36
      assert has_element?(view, ~s(#client-error [aria-label="Dismiss notice"]))
37 37
    end

@@ -40,7 +40,7 @@ defmodule OpenAgentsWeb.IconAffordancesTest do

40 40
  describe "glyphs beside words" do
41 41
    test "are decorative, so the control is announced once", %{conn: conn} do
42 42
      conn = log_in_github_user(conn, "icon-decorative-user")
43
      {:ok, view, _html} = live(conn, ~p"/chat")
43
      {:ok, view, _html} = live(conn, ~p"/sarah")
44 44
45 45
      for id <- ~w(load-older) do
46 46
        refute has_element?(view, "##{id} svg[aria-label]"),
test/openagents_web/live/agent_surfaces_test.exs modified +18 -3

@@ -22,9 +22,11 @@ defmodule OpenAgentsWeb.AgentSurfacesTest do

22 22
      {:ok, view, _html} = live(conn, ~p"/leaderboard")
23 23
24 24
      refute has_element?(view, @section)
25
      refute has_element?(view, ~s(#sidebar a.sidebar-row__hit[href="/chat"]))
25
      refute has_element?(view, ~s(#sidebar a.sidebar-row__hit[href="/sarah"]))
26 26
      refute has_element?(view, ~s(#sidebar a.sidebar-row__hit[href="/memory"]))
27 27
      refute has_element?(view, ~s(#sidebar a.sidebar-row__hit[href="/computers"]))
28
      # The operator-only chat surface is hidden from a non-operator too.
29
      refute has_element?(view, ~s(#sidebar a.sidebar-row__hit[href="/chat"]))
28 30
    end
29 31
30 32
    test "an account that has written to her does", %{conn: conn} do

@@ -32,9 +34,10 @@ defmodule OpenAgentsWeb.AgentSurfacesTest do

32 34
      {:ok, view, _html} = live(conn, ~p"/leaderboard")
33 35
34 36
      assert has_element?(view, @section)
35
      assert has_element?(view, ~s(#sidebar a.sidebar-row__hit[href="/chat"]))
37
      assert has_element?(view, ~s(#sidebar a.sidebar-row__hit[href="/sarah"]))
36 38
      assert has_element?(view, ~s(#sidebar a.sidebar-row__hit[href="/memory"]))
37 39
      assert has_element?(view, ~s(#sidebar a.sidebar-row__hit[href="/computers"]))
40
      refute has_element?(view, ~s(#sidebar a.sidebar-row__hit[href="/chat"]))
38 41
    end
39 42
40 43
    test "an operator does, without having written", %{conn: conn} do

@@ -42,6 +45,18 @@ defmodule OpenAgentsWeb.AgentSurfacesTest do

42 45
      {:ok, view, _html} = live(conn, ~p"/leaderboard")
43 46
44 47
      assert has_element?(view, @section)
48
      assert has_element?(view, ~s(#sidebar a.sidebar-row__hit[href="/sarah"]))
49
      # The operator's own chat surface sits in the main nav, above
50
      # Repositories.
51
      assert has_element?(view, ~s(#sidebar a.sidebar-row__hit[href="/chat"]))
52
53
      html = render(view)
54
      assert {chat_at, _len} = :binary.match(html, ~s(href="/chat"))
55
56
      assert {repositories_at, _len} =
57
               :binary.match(html, ~s(href="/repositories"))
58
59
      assert chat_at < repositories_at
45 60
    end
46 61
47 62
    test "the rest of the sidebar is unaffected either way", %{conn: conn} do

@@ -59,7 +74,7 @@ defmodule OpenAgentsWeb.AgentSurfacesTest do

59 74
  describe "earning her" do
60 75
    test "the first message reveals the section without a reload", %{conn: conn} do
61 76
      conn = log_in_github_user(conn, "first-message-account")
62
      {:ok, view, _html} = live(conn, ~p"/chat")
77
      {:ok, view, _html} = live(conn, ~p"/sarah")
63 78
64 79
      refute has_element?(view, @section)
65 80
test/openagents_web/live/changelog_live_test.exs modified +1 -1

@@ -62,7 +62,7 @@ defmodule OpenAgentsWeb.ChangelogLiveTest do

62 62
63 63
    # Chat is a sidebar row, so the page carries no chip back to it.
64 64
    refute html =~ ~s(id="return-to-conversation")
65
    assert html =~ ~s(href="/chat")
65
    assert html =~ ~s(href="/sarah")
66 66
  end
67 67
68 68
  test "publishes no node internals", %{conn: conn} do
test/openagents_web/live/chat_delegation_rail_test.exs modified +6 -6

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

11 11
12 12
  test "no work and no delegation means no rail at all", %{conn: conn} do
13 13
    conn = log_in_github_user(conn, "delegation-idle-browser")
14
    {:ok, view, _html} = live(conn, ~p"/chat")
14
    {:ok, view, _html} = live(conn, ~p"/sarah")
15 15
16 16
    refute has_element?(view, "#chat-rail")
17 17
    refute has_element?(view, "#delegation-rail")

@@ -23,7 +23,7 @@ defmodule OpenAgentsWeb.ChatDelegationRailTest do

23 23
    %{conn: conn, machine: machine} =
24 24
      delegation_owner(conn, "delegation-live-browser", "rail-box")
25 25
26
    {:ok, view, _html} = live(conn, ~p"/chat")
26
    {:ok, view, _html} = live(conn, ~p"/sarah")
27 27
28 28
    caller = start_delegation(machine, "claude")
29 29
    FakeController.chunk(caller.pid, caller.request_id, "hello from the machine")

@@ -93,7 +93,7 @@ defmodule OpenAgentsWeb.ChatDelegationRailTest do

93 93
    %{conn: conn, machine: machine} =
94 94
      delegation_owner(conn, "delegation-truncation-browser", "cap-box")
95 95
96
    {:ok, view, _html} = live(conn, ~p"/chat")
96
    {:ok, view, _html} = live(conn, ~p"/sarah")
97 97
98 98
    caller = start_delegation(machine, "claude")
99 99
    filler = String.duplicate("a", @maximum_event_bytes)

@@ -121,7 +121,7 @@ defmodule OpenAgentsWeb.ChatDelegationRailTest do

121 121
      delegation_owner(conn, "delegation-supersede-browser", "first-box")
122 122
123 123
    second_machine = paired_machine(user, "second-box")
124
    {:ok, view, _html} = live(conn, ~p"/chat")
124
    {:ok, view, _html} = live(conn, ~p"/sarah")
125 125
126 126
    first = start_delegation(first_machine, "claude")
127 127
    FakeController.chunk(first.pid, first.request_id, "first delegation working")

@@ -164,8 +164,8 @@ defmodule OpenAgentsWeb.ChatDelegationRailTest do

164 164
      delegation_owner(conn, "delegation-owner-browser", "owned-box")
165 165
166 166
    other_conn = log_in_github_user(build_conn(), "delegation-outsider-browser")
167
    {:ok, owner_view, _owner_html} = live(owner_conn, ~p"/chat")
168
    {:ok, other_view, _other_html} = live(other_conn, ~p"/chat")
167
    {:ok, owner_view, _owner_html} = live(owner_conn, ~p"/sarah")
168
    {:ok, other_view, _other_html} = live(other_conn, ~p"/sarah")
169 169
170 170
    caller = start_delegation(machine, "claude")
171 171
    FakeController.chunk(caller.pid, caller.request_id, "owner-only progress")
test/openagents_web/live/chat_live_test.exs modified +30 -30

@@ -9,7 +9,7 @@ defmodule OpenAgentsWeb.ChatLiveTest do

9 9
10 10
  test "the composer takes focus when the conversation opens", %{conn: conn} do
11 11
    conn = log_in_github_user(conn, "composer-focus-browser")
12
    {:ok, view, _html} = live(conn, ~p"/chat")
12
    {:ok, view, _html} = live(conn, ~p"/sarah")
13 13
14 14
    # Declarative rather than a hook call, so it also fires when the composer
15 15
    # remounts - returning from the memory surface, for instance.

@@ -18,7 +18,7 @@ defmodule OpenAgentsWeb.ChatLiveTest do

18 18
19 19
  test "the composer error renders as the eyebrow inside the card", %{conn: conn} do
20 20
    conn = log_in_github_user(conn, "composer-eyebrow-browser")
21
    {:ok, view, _html} = live(conn, ~p"/chat")
21
    {:ok, view, _html} = live(conn, ~p"/sarah")
22 22
23 23
    view
24 24
    |> form("#message-form", chat: %{message: "   "})

@@ -35,7 +35,7 @@ defmodule OpenAgentsWeb.ChatLiveTest do

35 35
36 36
  test "sending a message resets the composer so the draft cannot stick", %{conn: conn} do
37 37
    conn = log_in_github_user(conn, "composer-clear-browser")
38
    {:ok, view, _html} = live(conn, ~p"/chat")
38
    {:ok, view, _html} = live(conn, ~p"/sarah")
39 39
40 40
    view
41 41
    |> form("#message-form", chat: %{message: "The draft must not remain after send."})

@@ -55,7 +55,7 @@ defmodule OpenAgentsWeb.ChatLiveTest do

55 55
  test "the sidebar carries the shared destinations for an account that has written",
56 56
       %{conn: conn} do
57 57
    conn = log_in_chatting_user(conn, "computers-nav-browser")
58
    {:ok, view, _html} = live(conn, ~p"/chat")
58
    {:ok, view, _html} = live(conn, ~p"/sarah")
59 59
60 60
    # Computers, Memory and Leaderboard are destinations for everyone, so they
61 61
    # sit in the application sidebar rather than appearing only on chat.

@@ -68,7 +68,7 @@ defmodule OpenAgentsWeb.ChatLiveTest do

68 68
69 69
  test "chat contributes its rows to the one application sidebar", %{conn: conn} do
70 70
    conn = log_in_github_user(conn, "sidebar-shell-browser")
71
    {:ok, view, _html} = live(conn, ~p"/chat")
71
    {:ok, view, _html} = live(conn, ~p"/sarah")
72 72
73 73
    # Chat used to render a second, complete application shell inside the
74 74
    # first: its own brand, rail and account footer, nested in the padded main

@@ -107,11 +107,11 @@ defmodule OpenAgentsWeb.ChatLiveTest do

107 107
108 108
  test "the admin row renders only for an operator", %{conn: conn} do
109 109
    conn = log_in_github_user(conn, "admin-chip-hidden-browser")
110
    {:ok, _view, html} = live(conn, ~p"/chat")
110
    {:ok, _view, html} = live(conn, ~p"/sarah")
111 111
    refute html =~ ~s(id="open-admin")
112 112
113 113
    conn = log_in_admin_user(recycle(conn), "admin-chip-visible-browser")
114
    {:ok, view, _html} = live(conn, ~p"/chat")
114
    {:ok, view, _html} = live(conn, ~p"/sarah")
115 115
    # Admin is one row in the sidebar footer for an operator on every page,
116 116
    # rather than a row that exists only on chat.
117 117
    assert has_element?(view, ~s(#sidebar .sidebar-footer #open-admin[href="/admin"]))

@@ -123,12 +123,12 @@ defmodule OpenAgentsWeb.ChatLiveTest do

123 123
124 124
    Application.put_env(:openagents, :conversation_reset_enabled, true)
125 125
    conn = log_in_github_user(conn, "reset-visible-browser")
126
    {:ok, _view, html} = live(conn, ~p"/chat")
126
    {:ok, _view, html} = live(conn, ~p"/sarah")
127 127
    assert html =~ ~s(id="reset-conversation")
128 128
129 129
    Application.put_env(:openagents, :conversation_reset_enabled, false)
130 130
    conn = log_in_github_user(recycle(conn), "reset-hidden-browser")
131
    {:ok, _view, html} = live(conn, ~p"/chat")
131
    {:ok, _view, html} = live(conn, ~p"/sarah")
132 132
    refute html =~ ~s(id="reset-conversation")
133 133
  end
134 134

@@ -149,7 +149,7 @@ defmodule OpenAgentsWeb.ChatLiveTest do

149 149
  test "presents one continuing conversation after authentication", %{conn: conn} do
150 150
    user = github_user("live-browser")
151 151
    conn = log_in_github_user(conn, "live-browser")
152
    assert {:ok, view, html} = live(conn, ~p"/chat")
152
    assert {:ok, view, html} = live(conn, ~p"/sarah")
153 153
154 154
    assert html =~ "OpenAgents"
155 155
    assert html =~ "Message Sarah"

@@ -195,7 +195,7 @@ defmodule OpenAgentsWeb.ChatLiveTest do

195 195
    end)
196 196
197 197
    conn = log_in_github_user(conn, "voice-recording-off-user")
198
    assert {:ok, view, html} = live(conn, ~p"/chat")
198
    assert {:ok, view, html} = live(conn, ~p"/sarah")
199 199
200 200
    assert html =~ ~s(id="voice-controller")
201 201
    refute has_element?(view, "#voice-recording-disclosure")

@@ -205,7 +205,7 @@ defmodule OpenAgentsWeb.ChatLiveTest do

205 205
  test "a connected LiveView refuses events after the account is banned", %{conn: conn} do
206 206
    user = github_user("live-ban-user")
207 207
    conn = log_in_github_user(conn, "live-ban-user")
208
    assert {:ok, view, _html} = live(conn, ~p"/chat")
208
    assert {:ok, view, _html} = live(conn, ~p"/sarah")
209 209
    assert {:ok, _banned} = OpenAgents.Accounts.ban_user(user, "manual_abuse_review")
210 210
211 211
    view |> form("#message-form", chat: %{message: "still here?"}) |> render_submit()

@@ -221,7 +221,7 @@ defmodule OpenAgentsWeb.ChatLiveTest do

221 221
    token = "voice-live-browser-credential-000000000000000000"
222 222
    user = github_user(token)
223 223
    conn = log_in_github_user(conn, token)
224
    assert {:ok, view, html} = live(conn, ~p"/chat")
224
    assert {:ok, view, html} = live(conn, ~p"/sarah")
225 225
226 226
    assert html =~ ~s(id="voice-controller")
227 227
    assert html =~ ~s(phx-hook="VoiceController")

@@ -291,7 +291,7 @@ defmodule OpenAgentsWeb.ChatLiveTest do

291 291
    token = "voice-typed-inject-credential-00000000000000000"
292 292
    user = github_user(token)
293 293
    conn = log_in_github_user(conn, token)
294
    assert {:ok, view, _html} = live(conn, ~p"/chat")
294
    assert {:ok, view, _html} = live(conn, ~p"/sarah")
295 295
296 296
    conversation = Conversations.get_conversation_for_user(user)
297 297

@@ -340,7 +340,7 @@ defmodule OpenAgentsWeb.ChatLiveTest do

340 340
  } do
341 341
    user = github_user("live-voice-delta-browser")
342 342
    conn = log_in_github_user(conn, "live-voice-delta-browser")
343
    assert {:ok, view, _html} = live(conn, ~p"/chat")
343
    assert {:ok, view, _html} = live(conn, ~p"/sarah")
344 344
    conversation = Conversations.get_conversation_for_user(user)
345 345
346 346
    send(view.pid, {

@@ -394,9 +394,9 @@ defmodule OpenAgentsWeb.ChatLiveTest do

394 394
    user = github_user(key)
395 395
    conn = log_in_github_user(conn, key)
396 396
397
    assert {:ok, first_view, first_html} = live(conn, ~p"/chat")
397
    assert {:ok, first_view, first_html} = live(conn, ~p"/sarah")
398 398
    GenServer.stop(first_view.pid)
399
    assert {:ok, _second_view, second_html} = live(conn, ~p"/chat")
399
    assert {:ok, _second_view, second_html} = live(conn, ~p"/sarah")
400 400
401 401
    assert first_html =~ "Hello. I&#39;m Sarah—an OpenAgent. What are we working on?"
402 402
    assert second_html =~ "Hello. I&#39;m Sarah—an OpenAgent. What are we working on?"

@@ -419,7 +419,7 @@ defmodule OpenAgentsWeb.ChatLiveTest do

419 419
420 420
  test "turn execution receives the installed Sarah persona and default role", %{conn: conn} do
421 421
    conn = log_in_github_user(conn, "persona-browser")
422
    assert {:ok, view, _html} = live(conn, ~p"/chat")
422
    assert {:ok, view, _html} = live(conn, ~p"/sarah")
423 423
424 424
    view
425 425
    |> form("#message-form", chat: %{message: "[inspect-persona]"})

@@ -434,7 +434,7 @@ defmodule OpenAgentsWeb.ChatLiveTest do

434 434
  test "sends, streams, and durably stores a complete turn", %{conn: conn} do
435 435
    user = github_user("durable-turn-user")
436 436
    conn = log_in_github_user(conn, "durable-turn-user")
437
    assert {:ok, view, _html} = live(conn, ~p"/chat")
437
    assert {:ok, view, _html} = live(conn, ~p"/sarah")
438 438
    conversation = Conversations.get_conversation_for_user(user)
439 439
440 440
    view

@@ -486,7 +486,7 @@ defmodule OpenAgentsWeb.ChatLiveTest do

486 486
487 487
  test "each message carries a labeled hover toolbar and an accessible timestamp", %{conn: conn} do
488 488
    conn = log_in_github_user(conn, "toolbar-browser-credential-000000000000000000000")
489
    assert {:ok, view, html} = live(conn, ~p"/chat")
489
    assert {:ok, view, html} = live(conn, ~p"/sarah")
490 490
491 491
    view
492 492
    |> form("#message-form", chat: %{message: "Toolbar please."})

@@ -528,7 +528,7 @@ defmodule OpenAgentsWeb.ChatLiveTest do

528 528
    assert {:ok, _running_step, :started} = Conversations.start_tool_step(step)
529 529
530 530
    conn = log_in_github_user(conn, token)
531
    assert {:ok, view, html} = live(conn, ~p"/chat")
531
    assert {:ok, view, html} = live(conn, ~p"/sarah")
532 532
533 533
    # The event header renders the durable scrubbed values: the subject
534 534
    # sentence plus the bounded argument excerpt, per issue #79.

@@ -568,7 +568,7 @@ defmodule OpenAgentsWeb.ChatLiveTest do

568 568
    %{turn: turn, receipt: receipt, step: step} = begin_tool_turn(token, "bounded-query")
569 569
570 570
    conn = log_in_github_user(conn, token)
571
    assert {:ok, view, _html} = live(conn, ~p"/chat")
571
    assert {:ok, view, _html} = live(conn, ~p"/sarah")
572 572
    assert render(view) =~ "Getting ready for a look back through this conversation"
573 573
574 574
    assert {:ok, _running_step, :started} = Conversations.start_tool_step(step)

@@ -643,7 +643,7 @@ defmodule OpenAgentsWeb.ChatLiveTest do

643 643
644 644
    conn = log_in_github_user(conn, "tool-cancel-live-browser-credential-000000000000")
645 645
646
    assert {:ok, view, _html} = live(conn, ~p"/chat")
646
    assert {:ok, view, _html} = live(conn, ~p"/sarah")
647 647
648 648
    view
649 649
    |> form("#message-form", chat: %{message: "[cancel-tool-loop]"})

@@ -677,7 +677,7 @@ defmodule OpenAgentsWeb.ChatLiveTest do

677 677
    on_exit(fn -> Application.delete_env(:openagents, :test_tool_observer) end)
678 678
679 679
    conn = log_in_github_user(conn, "queue-live-browser-credential-00000000000000")
680
    assert {:ok, view, _html} = live(conn, ~p"/chat")
680
    assert {:ok, view, _html} = live(conn, ~p"/sarah")
681 681
682 682
    # Start a turn that stays active (its tool blocks).
683 683
    view |> form("#message-form", chat: %{message: "[cancel-tool-loop]"}) |> render_submit()

@@ -726,7 +726,7 @@ defmodule OpenAgentsWeb.ChatLiveTest do

726 726
727 727
    local_user = github_user(local_token)
728 728
    local_conn = log_in_github_user(conn, local_token)
729
    assert {:ok, local_view, local_html} = live(local_conn, ~p"/chat")
729
    assert {:ok, local_view, local_html} = live(local_conn, ~p"/sarah")
730 730
    refute local_html =~ "private-live-boundary-marker-73"
731 731
732 732
    local_view

@@ -791,7 +791,7 @@ defmodule OpenAgentsWeb.ChatLiveTest do

791 791
    on_exit(fn -> Application.delete_env(:openagents, :test_provider_observer) end)
792 792
793 793
    conn = log_in_github_user(conn, token)
794
    assert {:ok, view, html} = live(conn, ~p"/chat")
794
    assert {:ok, view, html} = live(conn, ~p"/sarah")
795 795
    refute html =~ secret
796 796
    refute inspect(:sys.get_state(view.pid).socket.assigns) =~ secret
797 797

@@ -855,7 +855,7 @@ defmodule OpenAgentsWeb.ChatLiveTest do

855 855
856 856
  test "the transcript is a conversation of messages, not a hand-rolled scroller", %{conn: conn} do
857 857
    conn = log_in_github_user(conn, "conversation-composition-browser-00000000")
858
    {:ok, view, _html} = live(conn, ~p"/chat")
858
    {:ok, view, _html} = live(conn, ~p"/sarah")
859 859
860 860
    # The scroller is `conversation/1`: a log region whose inner box scrolls,
861 861
    # so the return-to-newest control can hold still against the bottom edge.

@@ -883,7 +883,7 @@ defmodule OpenAgentsWeb.ChatLiveTest do

883 883
    on_exit(fn -> Application.delete_env(:openagents, :test_tool_observer) end)
884 884
885 885
    conn = log_in_github_user(conn, "prompt-input-composition-browser-000000")
886
    {:ok, view, _html} = live(conn, ~p"/chat")
886
    {:ok, view, _html} = live(conn, ~p"/sarah")
887 887
888 888
    # The form is `prompt_input/1`: its hook owns Enter-to-submit and the
889 889
    # auto-resize the retired `.Composer` hook used to do by hand.

@@ -917,7 +917,7 @@ defmodule OpenAgentsWeb.ChatLiveTest do

917 917
    on_exit(fn -> Application.delete_env(:openagents, :test_tool_observer) end)
918 918
919 919
    conn = log_in_github_user(conn, "queue-composition-browser-0000000000000")
920
    {:ok, view, _html} = live(conn, ~p"/chat")
920
    {:ok, view, _html} = live(conn, ~p"/sarah")
921 921
922 922
    view |> form("#message-form", chat: %{message: "[cancel-tool-loop]"}) |> render_submit()
923 923
    assert_receive {:test_tool_executed, _tool_pid, "block", _scope_ref}, 1_000

@@ -950,7 +950,7 @@ defmodule OpenAgentsWeb.ChatLiveTest do

950 950
    %{turn: turn, step: step} = begin_tool_turn(token, "composition-query")
951 951
952 952
    conn = log_in_github_user(conn, token)
953
    {:ok, view, _html} = live(conn, ~p"/chat")
953
    {:ok, view, _html} = live(conn, ~p"/sarah")
954 954
955 955
    # Requested, before the step starts: `tool_status_badge/1` reads
956 956
    # `input-streaming`, which it labels Pending.
test/openagents_web/live/chat_placeholder_test.exs added +42

@@ -0,0 +1,42 @@

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-empty")
32
    assert has_element?(view, ~s(a[href="/sarah"]))
33
  end
34
35
  test "a non-operator on a live socket is still turned away by the mount hook", %{conn: conn} do
36
    # The plug gate runs on the initial document request; this drives the
37
    # LiveView mount directly so the on_mount hook is what answers.
38
    conn = log_in_chatting_user(conn, "socket-not-an-operator")
39
40
    assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/chat")
41
  end
42
end
test/openagents_web/live/chat_sidebar_sections_test.exs modified +4 -4

@@ -6,7 +6,7 @@ defmodule OpenAgentsWeb.ChatSidebarSectionsTest do

6 6
7 7
  test "the work section is hidden while there is no background work", %{conn: conn} do
8 8
    conn = log_in_github_user(conn, "sidebar-sections-empty-browser")
9
    {:ok, view, _html} = live(conn, ~p"/chat")
9
    {:ok, view, _html} = live(conn, ~p"/sarah")
10 10
11 11
    refute has_element?(view, "#sidebar-work")
12 12

@@ -32,7 +32,7 @@ defmodule OpenAgentsWeb.ChatSidebarSectionsTest do

32 32
    {:ok, running} = Work.mark_job_running(job, %{})
33 33
    {:ok, finished} = Work.finish_job(running.id, "completed")
34 34
35
    {:ok, view, _html} = live(conn, ~p"/chat")
35
    {:ok, view, _html} = live(conn, ~p"/sarah")
36 36
37 37
    assert has_element?(view, ~s(#sidebar-job-#{job.id}[data-status="completed"]))
38 38
    assert has_element?(view, "#sidebar-job-#{job.id} .sidebar-row__title", "Collect the release")

@@ -65,7 +65,7 @@ defmodule OpenAgentsWeb.ChatSidebarSectionsTest do

65 65
        goal: "Rebuild the staging index"
66 66
      })
67 67
68
    {:ok, view, _html} = live(conn, ~p"/chat")
68
    {:ok, view, _html} = live(conn, ~p"/sarah")
69 69
70 70
    # Both placements are in the document and the stylesheet shows exactly one:
71 71
    # the sidebar section below 1280px, the rail above it.

@@ -82,7 +82,7 @@ defmodule OpenAgentsWeb.ChatSidebarSectionsTest do

82 82
    user = github_user("sidebar-sections-job-broadcast-browser")
83 83
    conn = log_in_github_user(conn, "sidebar-sections-job-broadcast-browser")
84 84
85
    {:ok, view, _html} = live(conn, ~p"/chat")
85
    {:ok, view, _html} = live(conn, ~p"/sarah")
86 86
    refute has_element?(view, "#sidebar-work")
87 87
88 88
    {:ok, conversation} = Conversations.ensure_conversation(user)
test/openagents_web/live/computers_navigation_test.exs modified +1 -1

@@ -9,7 +9,7 @@ defmodule OpenAgentsWeb.ComputersNavigationTest do

9 9
    assert has_element?(view, "#computers-page")
10 10
    assert has_element?(view, "#computers-manager")
11 11
    refute has_element?(view, "header.command-bar")
12
    assert has_element?(view, ~s(#sidebar a.sidebar-row__hit[href="/chat"]))
12
    assert has_element?(view, ~s(#sidebar a.sidebar-row__hit[href="/sarah"]))
13 13
    assert has_element?(view, "#account-bar-trigger")
14 14
  end
15 15
test/openagents_web/live/leaderboard_live_test.exs modified +1 -1

@@ -55,7 +55,7 @@ defmodule OpenAgentsWeb.LeaderboardLiveTest do

55 55
    assert html =~ ~s(id="account-bar-trigger")
56 56
    # Chat is a sidebar row, so the page carries no chip back to it.
57 57
    refute html =~ ~s(id="return-to-conversation")
58
    assert html =~ ~s(href="/chat")
58
    assert html =~ ~s(href="/sarah")
59 59
  end
60 60
61 61
  test "explains itself when no account has spent a token", %{conn: conn} do

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