| 1 |
|
- |
defmodule OpenAgentsWeb.ChatDelegationRailTest do
|
| 2 |
|
- |
use OpenAgentsWeb.ConnCase
|
| 3 |
|
- |
import Phoenix.LiveViewTest
|
| 4 |
|
- |
|
| 5 |
|
- |
alias OpenAgents.{Computer, Conversations, Machines}
|
| 6 |
|
- |
alias OpenAgents.Support.FakeController
|
| 7 |
|
- |
|
| 8 |
|
- |
# Mirrors the projection's per-event byte cap; four such chunks reach the
|
| 9 |
|
- |
# 65,536-byte cumulative cap.
|
| 10 |
|
- |
@maximum_event_bytes 16_384
|
| 11 |
|
- |
|
| 12 |
|
- |
test "no work and no delegation means no rail at all", %{conn: conn} do
|
| 13 |
|
- |
conn = log_in_github_user(conn, "delegation-idle-browser")
|
| 14 |
|
- |
{:ok, view, _html} = live(conn, ~p"/sarah")
|
| 15 |
|
- |
|
| 16 |
|
- |
refute has_element?(view, "#chat-rail")
|
| 17 |
|
- |
refute has_element?(view, "#delegation-rail")
|
| 18 |
|
- |
refute has_element?(view, "#delegation-inline")
|
| 19 |
|
- |
end
|
| 20 |
|
- |
|
| 21 |
|
- |
test "a streamed delegation renders the rail, the inline panel, and pushes chunks",
|
| 22 |
|
- |
%{conn: conn} do
|
| 23 |
|
- |
%{conn: conn, machine: machine} =
|
| 24 |
|
- |
delegation_owner(conn, "delegation-live-browser", "rail-box")
|
| 25 |
|
- |
|
| 26 |
|
- |
{:ok, view, _html} = live(conn, ~p"/sarah")
|
| 27 |
|
- |
|
| 28 |
|
- |
caller = start_delegation(machine, "claude")
|
| 29 |
|
- |
FakeController.chunk(caller.pid, caller.request_id, "hello from the machine")
|
| 30 |
|
- |
|
| 31 |
|
- |
# The chunk rides a push event to the log hooks, never an assign; once it
|
| 32 |
|
- |
# arrives, the start event has necessarily been applied too.
|
| 33 |
|
- |
assert_push_event(view, "delegation:chunk", %{text: "hello from the machine"}, 1_000)
|
| 34 |
|
- |
|
| 35 |
|
- |
# Desktop rail: the delegation is a section of the work rail beside the
|
| 36 |
|
- |
# transcript, never a block under the composer.
|
| 37 |
|
- |
assert has_element?(view, "#chat-rail #chat-rail-body #delegation-rail")
|
| 38 |
|
- |
assert has_element?(view, "#delegation-rail #delegation-live[data-status='running']")
|
| 39 |
|
- |
assert has_element?(view, "#delegation-live .delegation-live__computer", "rail-box")
|
| 40 |
|
- |
assert has_element?(view, "#delegation-live .delegation-live__subject", "claude")
|
| 41 |
|
- |
assert has_element?(view, ~s(#cancel-delegation[aria-label="Cancel delegation"]))
|
| 42 |
|
- |
assert has_element?(view, "#delegation-live div.delegation-log[phx-update='ignore']")
|
| 43 |
|
- |
assert has_element?(view, "#delegation-live time[data-started-at]")
|
| 44 |
|
- |
|
| 45 |
|
- |
assert has_element?(
|
| 46 |
|
- |
view,
|
| 47 |
|
- |
~s(#chat-rail-toggle[aria-label="Toggle work panel"][aria-controls="chat-rail-body"])
|
| 48 |
|
- |
)
|
| 49 |
|
- |
|
| 50 |
|
- |
# Collapse is a server assign so it survives the rail re-rendering on every
|
| 51 |
|
- |
# streamed chunk: the toggle flips it and it stays flipped.
|
| 52 |
|
- |
assert has_element?(view, ~s(#chat-rail[data-collapsed="false"]))
|
| 53 |
|
- |
view |> element("#chat-rail-toggle") |> render_click()
|
| 54 |
|
- |
assert has_element?(view, ~s(#chat-rail[data-collapsed="true"]))
|
| 55 |
|
- |
assert has_element?(view, ~s(#chat-rail-toggle[aria-expanded="false"]))
|
| 56 |
|
- |
view |> element("#chat-rail-toggle") |> render_click()
|
| 57 |
|
- |
assert has_element?(view, ~s(#chat-rail[data-collapsed="false"]))
|
| 58 |
|
- |
|
| 59 |
|
- |
# Narrow-viewport variant: the same projection as an expandable
|
| 60 |
|
- |
# event-header section at the transcript tail, live log inside.
|
| 61 |
|
- |
assert has_element?(view, "#delegation-inline #delegation-inline-header.event-header")
|
| 62 |
|
- |
assert has_element?(view, "#delegation-inline div.delegation-log")
|
| 63 |
|
- |
|
| 64 |
|
- |
FakeController.exit(caller.pid, caller.request_id, %{
|
| 65 |
|
- |
"status" => "completed",
|
| 66 |
|
- |
"stop_reason" => "end_turn",
|
| 67 |
|
- |
"session_id" => "acp-rail-1",
|
| 68 |
|
- |
"truncated" => false,
|
| 69 |
|
- |
"duration_ms" => 12_000
|
| 70 |
|
- |
})
|
| 71 |
|
- |
|
| 72 |
|
- |
assert {:ok, _result} = Task.await(caller.task)
|
| 73 |
|
- |
|
| 74 |
|
- |
# Terminal: the panel collapses to a typed summary line with the status
|
| 75 |
|
- |
# word, stop reason, and duration, plus a dismiss control.
|
| 76 |
|
- |
assert eventually(fn -> has_element?(view, "#delegation-terminal") end)
|
| 77 |
|
- |
refute has_element?(view, "#delegation-live")
|
| 78 |
|
- |
assert has_element?(view, "#delegation-terminal .delegation-summary__meta", "SUCCEEDED")
|
| 79 |
|
- |
assert has_element?(view, "#delegation-terminal .delegation-summary__meta", "end_turn")
|
| 80 |
|
- |
assert has_element?(view, "#delegation-terminal .delegation-summary__meta", "12s")
|
| 81 |
|
- |
assert has_element?(view, ~s(#delegation-dismiss[aria-label="Dismiss"]))
|
| 82 |
|
- |
|
| 83 |
|
- |
# Dismissing clears the whole ephemeral projection; the durable event
|
| 84 |
|
- |
# header in the transcript remains the record. With no work either, the
|
| 85 |
|
- |
# rail has nothing left to state and goes with it.
|
| 86 |
|
- |
view |> element("#delegation-dismiss") |> render_click()
|
| 87 |
|
- |
refute has_element?(view, "#delegation-rail")
|
| 88 |
|
- |
refute has_element?(view, "#delegation-inline")
|
| 89 |
|
- |
refute has_element?(view, "#chat-rail")
|
| 90 |
|
- |
end
|
| 91 |
|
- |
|
| 92 |
|
- |
test "the capped stream renders an explicit truncation marker", %{conn: conn} do
|
| 93 |
|
- |
%{conn: conn, machine: machine} =
|
| 94 |
|
- |
delegation_owner(conn, "delegation-truncation-browser", "cap-box")
|
| 95 |
|
- |
|
| 96 |
|
- |
{:ok, view, _html} = live(conn, ~p"/sarah")
|
| 97 |
|
- |
|
| 98 |
|
- |
caller = start_delegation(machine, "claude")
|
| 99 |
|
- |
filler = String.duplicate("a", @maximum_event_bytes)
|
| 100 |
|
- |
|
| 101 |
|
- |
for _fill <- 1..4, do: FakeController.chunk(caller.pid, caller.request_id, filler)
|
| 102 |
|
- |
FakeController.chunk(caller.pid, caller.request_id, "beyond the cap")
|
| 103 |
|
- |
|
| 104 |
|
- |
assert eventually(fn ->
|
| 105 |
|
- |
has_element?(view, "#delegation-rail .delegation-truncated", "TRUNCATED")
|
| 106 |
|
- |
end)
|
| 107 |
|
- |
|
| 108 |
|
- |
FakeController.exit(caller.pid, caller.request_id, %{
|
| 109 |
|
- |
"status" => "completed",
|
| 110 |
|
- |
"stop_reason" => "end_turn",
|
| 111 |
|
- |
"truncated" => true,
|
| 112 |
|
- |
"duration_ms" => 5
|
| 113 |
|
- |
})
|
| 114 |
|
- |
|
| 115 |
|
- |
assert {:ok, _result} = Task.await(caller.task)
|
| 116 |
|
- |
end
|
| 117 |
|
- |
|
| 118 |
|
- |
test "a newer delegation supersedes the panel; the older collapses to a summary",
|
| 119 |
|
- |
%{conn: conn} do
|
| 120 |
|
- |
%{conn: conn, user: user, machine: first_machine} =
|
| 121 |
|
- |
delegation_owner(conn, "delegation-supersede-browser", "first-box")
|
| 122 |
|
- |
|
| 123 |
|
- |
second_machine = paired_machine(user, "second-box")
|
| 124 |
|
- |
{:ok, view, _html} = live(conn, ~p"/sarah")
|
| 125 |
|
- |
|
| 126 |
|
- |
first = start_delegation(first_machine, "claude")
|
| 127 |
|
- |
FakeController.chunk(first.pid, first.request_id, "first delegation working")
|
| 128 |
|
- |
assert_push_event(view, "delegation:chunk", %{text: "first delegation working"}, 1_000)
|
| 129 |
|
- |
assert has_element?(view, "#delegation-live .delegation-live__computer", "first-box")
|
| 130 |
|
- |
|
| 131 |
|
- |
second = start_delegation(second_machine, "codex")
|
| 132 |
|
- |
FakeController.chunk(second.pid, second.request_id, "second delegation working")
|
| 133 |
|
- |
assert_push_event(view, "delegation:chunk", %{text: "second delegation working"}, 1_000)
|
| 134 |
|
- |
|
| 135 |
|
- |
# One live panel: the newest delegation owns it; the superseded one is a
|
| 136 |
|
- |
# bounded summary line beneath.
|
| 137 |
|
- |
assert has_element?(view, "#delegation-live .delegation-live__computer", "second-box")
|
| 138 |
|
- |
refute has_element?(view, "#delegation-live .delegation-live__computer", "first-box")
|
| 139 |
|
- |
assert has_element?(view, ".delegation-summary--superseded", "first-box")
|
| 140 |
|
- |
|
| 141 |
|
- |
for caller <- [first, second] do
|
| 142 |
|
- |
FakeController.exit(caller.pid, caller.request_id, %{
|
| 143 |
|
- |
"status" => "completed",
|
| 144 |
|
- |
"stop_reason" => "end_turn",
|
| 145 |
|
- |
"truncated" => false,
|
| 146 |
|
- |
"duration_ms" => 1
|
| 147 |
|
- |
})
|
| 148 |
|
- |
|
| 149 |
|
- |
assert {:ok, _result} = Task.await(caller.task)
|
| 150 |
|
- |
end
|
| 151 |
|
- |
|
| 152 |
|
- |
# The superseded summary picks up its own terminal status.
|
| 153 |
|
- |
assert eventually(fn ->
|
| 154 |
|
- |
has_element?(
|
| 155 |
|
- |
view,
|
| 156 |
|
- |
".delegation-summary--superseded .delegation-summary__meta",
|
| 157 |
|
- |
"SUCCEEDED"
|
| 158 |
|
- |
)
|
| 159 |
|
- |
end)
|
| 160 |
|
- |
end
|
| 161 |
|
- |
|
| 162 |
|
- |
test "another account's conversation never renders the delegation", %{conn: conn} do
|
| 163 |
|
- |
%{conn: owner_conn, machine: machine} =
|
| 164 |
|
- |
delegation_owner(conn, "delegation-owner-browser", "owned-box")
|
| 165 |
|
- |
|
| 166 |
|
- |
other_conn = log_in_github_user(build_conn(), "delegation-outsider-browser")
|
| 167 |
|
- |
{:ok, owner_view, _owner_html} = live(owner_conn, ~p"/sarah")
|
| 168 |
|
- |
{:ok, other_view, _other_html} = live(other_conn, ~p"/sarah")
|
| 169 |
|
- |
|
| 170 |
|
- |
caller = start_delegation(machine, "claude")
|
| 171 |
|
- |
FakeController.chunk(caller.pid, caller.request_id, "owner-only progress")
|
| 172 |
|
- |
assert_push_event(owner_view, "delegation:chunk", %{text: "owner-only progress"}, 1_000)
|
| 173 |
|
- |
assert has_element?(owner_view, "#delegation-rail")
|
| 174 |
|
- |
|
| 175 |
|
- |
# The topic is scoped to the owner's conversation, so the other account's
|
| 176 |
|
- |
# LiveView structurally never receives the stream.
|
| 177 |
|
- |
refute has_element?(other_view, "#delegation-rail")
|
| 178 |
|
- |
refute has_element?(other_view, "#delegation-inline")
|
| 179 |
|
- |
refute render(other_view) =~ "owner-only progress"
|
| 180 |
|
- |
|
| 181 |
|
- |
FakeController.exit(caller.pid, caller.request_id, %{
|
| 182 |
|
- |
"status" => "completed",
|
| 183 |
|
- |
"stop_reason" => "end_turn",
|
| 184 |
|
- |
"truncated" => false,
|
| 185 |
|
- |
"duration_ms" => 1
|
| 186 |
|
- |
})
|
| 187 |
|
- |
|
| 188 |
|
- |
assert {:ok, _result} = Task.await(caller.task)
|
| 189 |
|
- |
end
|
| 190 |
|
- |
|
| 191 |
|
- |
# Logs the account in, ensures its one conversation exists, and pairs one
|
| 192 |
|
- |
# machine so a delegation can target it.
|
| 193 |
|
- |
defp delegation_owner(conn, key, machine_name) do
|
| 194 |
|
- |
user = github_user(key)
|
| 195 |
|
- |
conn = log_in_github_user(conn, key)
|
| 196 |
|
- |
{:ok, _conversation} = Conversations.ensure_conversation(user)
|
| 197 |
|
- |
%{conn: conn, user: user, machine: paired_machine(user, machine_name)}
|
| 198 |
|
- |
end
|
| 199 |
|
- |
|
| 200 |
|
- |
defp paired_machine(user, name) do
|
| 201 |
|
- |
{:ok, %{code: code}} =
|
| 202 |
|
- |
Machines.start_pairing(%{
|
| 203 |
|
- |
"name" => name,
|
| 204 |
|
- |
"tier" => "curated",
|
| 205 |
|
- |
"platform" => "linux-x64",
|
| 206 |
|
- |
"agent_version" => "0.1.0",
|
| 207 |
|
- |
"roots" => []
|
| 208 |
|
- |
})
|
| 209 |
|
- |
|
| 210 |
|
- |
{:ok, machine} = Machines.approve_pairing(user, code)
|
| 211 |
|
- |
machine
|
| 212 |
|
- |
end
|
| 213 |
|
- |
|
| 214 |
|
- |
# Connects a fake controller for the machine and starts the delegation in a
|
| 215 |
|
- |
# task; the script hands the request correlation back so the test drives the
|
| 216 |
|
- |
# stream itself.
|
| 217 |
|
- |
defp start_delegation(machine, agent_id) do
|
| 218 |
|
- |
test_pid = self()
|
| 219 |
|
- |
|
| 220 |
|
- |
start_supervised!(
|
| 221 |
|
- |
{FakeController,
|
| 222 |
|
- |
machine_id: machine.id,
|
| 223 |
|
- |
script: fn {:agent, request_id, _payload, caller_pid} ->
|
| 224 |
|
- |
send(test_pid, {:delegation_request, machine.id, request_id, caller_pid})
|
| 225 |
|
- |
end},
|
| 226 |
|
- |
id: {FakeController, machine.id}
|
| 227 |
|
- |
)
|
| 228 |
|
- |
|
| 229 |
|
- |
task =
|
| 230 |
|
- |
Task.async(fn ->
|
| 231 |
|
- |
Computer.request_agent(machine.id, %{"agent_id" => agent_id, "prompt" => "work"}, 5_000)
|
| 232 |
|
- |
end)
|
| 233 |
|
- |
|
| 234 |
|
- |
machine_id = machine.id
|
| 235 |
|
- |
assert_receive {:delegation_request, ^machine_id, request_id, caller_pid}
|
| 236 |
|
- |
%{task: task, request_id: request_id, pid: caller_pid}
|
| 237 |
|
- |
end
|
| 238 |
|
- |
|
| 239 |
|
- |
defp eventually(assertion, timeout \\ 1_000) do
|
| 240 |
|
- |
deadline = System.monotonic_time(:millisecond) + timeout
|
| 241 |
|
- |
do_eventually(assertion, deadline)
|
| 242 |
|
- |
end
|
| 243 |
|
- |
|
| 244 |
|
- |
defp do_eventually(assertion, deadline) do
|
| 245 |
|
- |
if assertion.() do
|
| 246 |
|
- |
true
|
| 247 |
|
- |
else
|
| 248 |
|
- |
if System.monotonic_time(:millisecond) >= deadline do
|
| 249 |
|
- |
false
|
| 250 |
|
- |
else
|
| 251 |
|
- |
receive do
|
| 252 |
|
- |
_message -> :ok
|
| 253 |
|
- |
after
|
| 254 |
|
- |
10 -> :ok
|
| 255 |
|
- |
end
|
| 256 |
|
- |
|
| 257 |
|
- |
do_eventually(assertion, deadline)
|
| 258 |
|
- |
end
|
| 259 |
|
- |
end
|
| 260 |
|
- |
end
|
| 261 |
|
- |
end
|