| 841 |
846
|
|
refute response.resp_body =~ "INTERNAL_TEST_PAYLOAD"
|
| 842 |
847
|
|
end
|
| 843 |
848
|
|
|
|
849
|
+ |
# ── The AI Elements composition ─────────────────────────────────────────────
|
|
850
|
+ |
# The surface is built from the ported components rather than from bespoke
|
|
851
|
+ |
# chat CSS. What follows checks the composition itself: that the parts are
|
|
852
|
+ |
# the ported ones, and that the state each of them needs from the LiveView
|
|
853
|
+ |
# actually reaches it. A screenshot cannot tell a `div` that looks like a
|
|
854
|
+ |
# transcript from one the scroll hook can follow.
|
|
855
|
+ |
|
|
856
|
+ |
test "the transcript is a conversation of messages, not a hand-rolled scroller", %{conn: conn} do
|
|
857
|
+ |
conn = log_in_github_user(conn, "conversation-composition-browser-00000000")
|
|
858
|
+ |
{:ok, view, _html} = live(conn, ~p"/chat")
|
|
859
|
+ |
|
|
860
|
+ |
# The scroller is `conversation/1`: a log region whose inner box scrolls,
|
|
861
|
+ |
# so the return-to-newest control can hold still against the bottom edge.
|
|
862
|
+ |
assert has_element?(view, "#conversation[role=log]")
|
|
863
|
+ |
assert has_element?(view, ~s(#conversation-viewport[data-conversation-viewport="true"]))
|
|
864
|
+ |
assert has_element?(view, ~s(#conversation-scroll-button[data-conversation-scroll-button]))
|
|
865
|
+ |
|
|
866
|
+ |
# `.TranscriptScroll` wraps it rather than replacing it: the AI Elements
|
|
867
|
+ |
# hook pins to the newest turn, and this one keeps the copied-link anchor,
|
|
868
|
+ |
# the prepend position, and the server's scroll event.
|
|
869
|
+ |
assert has_element?(view, ~s(#transcript[phx-hook$=".TranscriptScroll"] #conversation))
|
|
870
|
+ |
|
|
871
|
+ |
# The turns are `conversation_content/1` holding the stream.
|
|
872
|
+ |
assert has_element?(view, "#conversation #conversation-content.message-list")
|
|
873
|
+ |
assert has_element?(view, ~s(#conversation-content #messages[phx-update="stream"]))
|
|
874
|
+ |
|
|
875
|
+ |
# Each turn is `message/1` carrying the marker `message_content/1` reads.
|
|
876
|
+ |
assert has_element?(view, ~s(#messages [data-from="assistant"].is-assistant))
|
|
877
|
+ |
assert has_element?(view, ~s([data-from="assistant"] .message-content))
|
|
878
|
+ |
assert has_element?(view, ~s([data-from="assistant"] .message-toolbar))
|
|
879
|
+ |
end
|
|
880
|
+ |
|
|
881
|
+ |
test "the composer is a prompt input that states its submit status", %{conn: conn} do
|
|
882
|
+ |
Application.put_env(:openagents, :test_tool_observer, self())
|
|
883
|
+ |
on_exit(fn -> Application.delete_env(:openagents, :test_tool_observer) end)
|
|
884
|
+ |
|
|
885
|
+ |
conn = log_in_github_user(conn, "prompt-input-composition-browser-000000")
|
|
886
|
+ |
{:ok, view, _html} = live(conn, ~p"/chat")
|
|
887
|
+ |
|
|
888
|
+ |
# The form is `prompt_input/1`: its hook owns Enter-to-submit and the
|
|
889
|
+ |
# auto-resize the retired `.Composer` hook used to do by hand.
|
|
890
|
+ |
assert has_element?(view, ~s(form#message-form[phx-hook$=".PromptInput"]))
|
|
891
|
+ |
assert has_element?(view, ~s(#message-form[data-submit-on-enter="true"]))
|
|
892
|
+ |
|
|
893
|
+ |
# The control is `prompt_input_textarea/1`, which is what makes it the
|
|
894
|
+ |
# input group's control rather than a textarea that happens to be inside.
|
|
895
|
+ |
assert has_element?(view, ~s(#chat_message[data-slot="input-group-control"]))
|
|
896
|
+ |
assert has_element?(view, ~s(#message-form [data-slot="input-group"] #chat_message))
|
|
897
|
+ |
|
|
898
|
+ |
# Idle: the send control is a submit button in the ready state.
|
|
899
|
+ |
assert has_element?(view, ~s(#send-message[type="submit"][data-status="ready"]))
|
|
900
|
+ |
|
|
901
|
+ |
view |> form("#message-form", chat: %{message: "[cancel-tool-loop]"}) |> render_submit()
|
|
902
|
+ |
assert_receive {:test_tool_executed, _tool_pid, "block", _scope_ref}, 1_000
|
|
903
|
+ |
|
|
904
|
+ |
# A turn is in flight: the status says so, and the control still submits,
|
|
905
|
+ |
# because a message sent now queues rather than being refused.
|
|
906
|
+ |
assert has_element?(view, ~s(#send-message[type="submit"][data-status="submitted"]))
|
|
907
|
+ |
assert has_element?(view, ~s(#send-message[aria-label="Queue message"]))
|
|
908
|
+ |
assert has_element?(view, "#message-form #cancel-turn")
|
|
909
|
+ |
|
|
910
|
+ |
view |> element("#cancel-turn") |> render_click()
|
|
911
|
+ |
|
|
912
|
+ |
assert eventually(fn -> has_element?(view, ~s(#send-message[data-status="ready"])) end)
|
|
913
|
+ |
end
|
|
914
|
+ |
|
|
915
|
+ |
test "queued messages are a queue, and each row still drops itself", %{conn: conn} do
|
|
916
|
+ |
Application.put_env(:openagents, :test_tool_observer, self())
|
|
917
|
+ |
on_exit(fn -> Application.delete_env(:openagents, :test_tool_observer) end)
|
|
918
|
+ |
|
|
919
|
+ |
conn = log_in_github_user(conn, "queue-composition-browser-0000000000000")
|
|
920
|
+ |
{:ok, view, _html} = live(conn, ~p"/chat")
|
|
921
|
+ |
|
|
922
|
+ |
view |> form("#message-form", chat: %{message: "[cancel-tool-loop]"}) |> render_submit()
|
|
923
|
+ |
assert_receive {:test_tool_executed, _tool_pid, "block", _scope_ref}, 1_000
|
|
924
|
+ |
|
|
925
|
+ |
view |> form("#message-form", chat: %{message: "Wait your turn."}) |> render_submit()
|
|
926
|
+ |
|
|
927
|
+ |
# `queue/1` holding one `queue_section/1`, which is a native details so the
|
|
928
|
+ |
# disclosure works before any JavaScript loads.
|
|
929
|
+ |
assert has_element?(view, ~s(#message-queue[data-slot="queue"]))
|
|
930
|
+ |
assert has_element?(view, "#message-queue details#message-queue-section[open] > summary")
|
|
931
|
+ |
assert has_element?(view, "#message-queue-section summary", "1 QUEUED")
|
|
932
|
+ |
|
|
933
|
+ |
# One `queue_item/1` per waiting message, still carrying its own control.
|
|
934
|
+ |
assert has_element?(view, "#message-queue li[id^=queued-]", "Wait your turn.")
|
|
935
|
+ |
|
|
936
|
+ |
assert has_element?(
|
|
937
|
+ |
view,
|
|
938
|
+ |
~s(#message-queue button[phx-click="dequeue_message"][aria-label="Remove queued message"])
|
|
939
|
+ |
)
|
|
940
|
+ |
|
|
941
|
+ |
view
|
|
942
|
+ |
|> element(~s(#message-queue button[phx-click="dequeue_message"]))
|
|
943
|
+ |
|> render_click()
|
|
944
|
+ |
|
|
945
|
+ |
refute has_element?(view, "#message-queue")
|
|
946
|
+ |
end
|
|
947
|
+ |
|
|
948
|
+ |
test "a tool call is a tool block whose badge states the step's real state", %{conn: conn} do
|
|
949
|
+ |
token = "tool-composition-browser-credential-000000000000"
|
|
950
|
+ |
%{turn: turn, step: step} = begin_tool_turn(token, "composition-query")
|
|
951
|
+ |
|
|
952
|
+ |
conn = log_in_github_user(conn, token)
|
|
953
|
+ |
{:ok, view, _html} = live(conn, ~p"/chat")
|
|
954
|
+ |
|
|
955
|
+ |
# Requested, before the step starts: `tool_status_badge/1` reads
|
|
956
|
+ |
# `input-streaming`, which it labels Pending.
|
|
957
|
+ |
assert has_element?(view, "details#tool-activity-step-#{step.id} > summary", "Pending")
|
|
958
|
+ |
|
|
959
|
+ |
assert {:ok, _running_step, :started} = Conversations.start_tool_step(step)
|
|
960
|
+ |
|
|
961
|
+ |
assert eventually(fn ->
|
|
962
|
+ |
has_element?(view, "#tool-activity-step-#{step.id} summary", "Running")
|
|
963
|
+ |
end)
|
|
964
|
+ |
|
|
965
|
+ |
assert {:ok, _refused_step} =
|
|
966
|
+ |
Conversations.complete_tool_step(
|
|
967
|
+ |
step,
|
|
968
|
+ |
tool_outcome(step, "refused", "policy_refused")
|
|
969
|
+ |
)
|
|
970
|
+ |
|
|
971
|
+ |
# A refusal is `output-denied`, not a generic error: the badge says Denied,
|
|
972
|
+ |
# and the expansion still states the exact durable status word.
|
|
973
|
+ |
assert eventually(fn ->
|
|
974
|
+ |
has_element?(view, "#tool-activity-step-#{step.id} summary", "Denied") and
|
|
975
|
+ |
has_element?(view, "#tool-activity-step-#{step.id}-details", "refused")
|
|
976
|
+ |
end)
|
|
977
|
+ |
|
|
978
|
+ |
assert {:ok, _cancelled_turn} = Conversations.cancel_turn(turn)
|
|
979
|
+ |
end
|
|
980
|
+ |
|
| 844 |
981
|
|
defp begin_tool_turn(token, query) do
|
| 845 |
982
|
|
assert {:ok, conversation} = Conversations.ensure_conversation(github_user(token))
|
| 846 |
983
|
|
assert {:ok, records} = Conversations.create_turn(conversation, "Recall something.")
|