diff --git a/.formatter.exs b/.formatter.exs index d304ff3..b2e206f 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,3 +1,4 @@ [ - inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs,heex}"] ] diff --git a/.gitignore b/.gitignore index 6f3fad9..f65c1fa 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,7 @@ /deps/ /doc/ /cover/ +/assets/node_modules/ +/priv/static/assets/app.js +/priv/static/assets/app.js.map /mix.lock diff --git a/README.md b/README.md index 4fe7be9..025688b 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,93 @@ agents that speak the Agent Client Protocol (ACP). It builds on - a long-lived session process that survives UI disconnects; - prompt, cancellation, permission, event, and diagnostic state management; - an `ExMCP.Transport` implementation backed by a pluggable process runner; -- interactive-shell startup isolation for remote execution environments; and -- optional notification and persistence callbacks. +- interactive-shell startup isolation for remote execution environments; +- optional notification and persistence callbacks; +- a reusable Phoenix LiveView conversation component; and +- a loopback-only web playground for launching local ACP agent commands. -The host application remains responsible for authorization, agent records, -credentials, persistence schemas, and presentation. +Host applications remain responsible for authorization, agent records, +credentials, and persistence schemas. They can provide an action adapter to +the shared conversation component while retaining their own authorization +boundary. + +## Local web playground + +The repository includes a runnable Phoenix application that launches an ACP +agent as a local stdio process and renders the same conversation component that +can be embedded in another Phoenix application. + +```sh +mix setup +mix phx.server +``` + +Open , then enter: + +- the ACP agent executable; +- one command argument per line; +- the repository working directory; and +- any additional `NAME=value` environment entries. + +The launch form can be prefilled from application configuration instead of +typing these values on every launch: + +```elixir +config :acp_runtime, + agent_process: [ + executable: "opencode", + args: ["acp"], + working_directory: "/absolute/path/to/project", + environment: %{} + ] +``` + +Values set in the form override the configured defaults, and unconfigured +fields fall back to their previous behavior (blank fields, server working +directory). + +For the locally installed OpenCode ACP server, use `opencode` as the executable +and `acp` as its single argument. + +The spawned process inherits the environment of the Phoenix server. Commands +are represented as an executable plus an argument list and are not evaluated +as a shell command string. + +The development endpoint binds to `127.0.0.1` and the playground route uses +only the Phoenix browser pipeline. It has no authentication and should not be +exposed on a public interface without adding an authentication boundary. + +## Shared LiveView component + +`ACPRuntimeWeb.ConversationComponent` owns the prompt form, streamed events, +permission controls, session status presentation, and safe Markdown rendering +for agent messages. A parent LiveView passes the initial state and forwards +`{:acp_status, state}` and +`{:acp_event, event}` messages with `Phoenix.LiveView.send_update/3`. + +```heex +<.live_component + module={ACPRuntimeWeb.ConversationComponent} + id="acp-conversation" + session_id={@session_id} + session_state={@session_state} + agent_name={@agent_name} + action_adapter={ACPRuntimeWeb.SessionActions} + action_context={@session_pid} +/> +``` + +Custom adapters implement `ACPRuntimeWeb.ConversationActions`. This lets a +host authorize prompt, cancellation, permission, and termination operations +before delegating to the runtime. + +The component's stylesheet is shipped at +`priv/static/assets/acp_runtime.css`. A Tailwind host can include it in its own +application bundle: + +```css +@import "../../deps/acp_runtime/priv/static/assets/acp_runtime.css"; +``` ## Supervision @@ -35,8 +117,12 @@ agent. See `ACPRuntime.ProcessRunner` for the required contract. This permits the same runtime to operate against local ports, containers, VMs, or remote execution services without depending on their SDKs. +`ACPRuntime.LocalProcessRunner` is the built-in implementation for the local +web playground. It uses an Erlang port and leaves agent stderr attached to the +Phoenix server's stderr so it cannot corrupt the ACP messages on stdout. + ## Publishing status -This project is currently consumed as a sibling path-dependency prototype. Public Hex publishing -requires a repository URL, an explicit license decision, package metadata, and -independent CI before the version is changed from `0.1.0-dev`. +This project is currently consumed as a sibling path-dependency prototype. +Public Hex publishing requires an explicit license decision, package metadata, +and independent CI before the version is changed from `0.1.0-dev`. diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..586d9d7 --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,10 @@ +import "phoenix_html" + +import {Socket} from "phoenix" +import {LiveSocket} from "phoenix_live_view" + +const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +const liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) + +liveSocket.connect() +window.liveSocket = liveSocket diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..6f4767a --- /dev/null +++ b/config/config.exs @@ -0,0 +1,34 @@ +import Config + +config :acp_runtime, + web_enabled: true, + agent_process: [ + executable: "opencode", + args: ["acp"], + working_directory: nil, + environment: %{} + ] + +config :acp_runtime, ACPRuntimeWeb.Endpoint, + url: [host: "localhost"], + adapter: Bandit.PhoenixAdapter, + render_errors: [formats: [html: ACPRuntimeWeb.ErrorHTML], layout: false], + pubsub_server: ACPRuntime.PubSub, + live_view: [signing_salt: "acpRuntimeLiveView"] + +config :esbuild, + version: "0.25.4", + acp_runtime: [ + args: + ~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), + cd: Path.expand("../assets", __DIR__), + env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]} + ] + +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +config :phoenix, :json_library, Jason + +import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..18bacee --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,14 @@ +import Config + +config :acp_runtime, ACPRuntimeWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: String.to_integer(System.get_env("PORT") || "4000")], + check_origin: false, + code_reloader: true, + debug_errors: true, + secret_key_base: "f7NzKjVVWQMVz8GmEsPBymEGFAPjHhxnmtvu7jseuPkxFNfMnLZSnzrXPWNCng5E", + watchers: [ + esbuild: {Esbuild, :install_and_run, [:acp_runtime, ~w(--sourcemap=inline --watch)]} + ] + +config :phoenix, :plug_init_mode, :runtime +config :phoenix_live_view, debug_heex_annotations: true, enable_expensive_runtime_checks: true diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..884b0c1 --- /dev/null +++ b/config/prod.exs @@ -0,0 +1,6 @@ +import Config + +config :acp_runtime, ACPRuntimeWeb.Endpoint, + cache_static_manifest: "priv/static/cache_manifest.json" + +config :logger, level: :info diff --git a/config/runtime.exs b/config/runtime.exs new file mode 100644 index 0000000..9fd42d1 --- /dev/null +++ b/config/runtime.exs @@ -0,0 +1,9 @@ +import Config + +if config_env() == :prod do + config :acp_runtime, ACPRuntimeWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: String.to_integer(System.get_env("PORT") || "4000")], + secret_key_base: + System.get_env("SECRET_KEY_BASE") || + raise("SECRET_KEY_BASE must be set when running the ACP Runtime web UI in production") +end diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..1e18a50 --- /dev/null +++ b/config/test.exs @@ -0,0 +1,10 @@ +import Config + +config :acp_runtime, ACPRuntimeWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4002], + secret_key_base: "f7NzKjVVWQMVz8GmEsPBymEGFAPjHhxnmtvu7jseuPkxFNfMnLZSnzrXPWNCng5E", + server: false + +config :logger, level: :warning +config :phoenix, :plug_init_mode, :runtime +config :phoenix_live_view, enable_expensive_runtime_checks: true diff --git a/lib/acp_runtime/application.ex b/lib/acp_runtime/application.ex new file mode 100644 index 0000000..b225d04 --- /dev/null +++ b/lib/acp_runtime/application.ex @@ -0,0 +1,35 @@ +defmodule ACPRuntime.Application do + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = + if Application.get_env(:acp_runtime, :web_enabled, false) do + [ + ACPRuntime.Supervisor, + {Registry, keys: :unique, name: ACPRuntime.LocalProcessRegistry}, + {DynamicSupervisor, name: ACPRuntime.LocalProcessSupervisor, strategy: :one_for_one}, + {Phoenix.PubSub, name: ACPRuntime.PubSub}, + ACPRuntimeWeb.Endpoint + ] + else + [] + end + + Supervisor.start_link(children, + strategy: :one_for_one, + name: ACPRuntime.ApplicationSupervisor + ) + end + + @impl true + def config_change(changed, _new, removed) do + if Application.get_env(:acp_runtime, :web_enabled, false) do + ACPRuntimeWeb.Endpoint.config_change(changed, removed) + end + + :ok + end +end diff --git a/lib/acp_runtime/local.ex b/lib/acp_runtime/local.ex new file mode 100644 index 0000000..318620f --- /dev/null +++ b/lib/acp_runtime/local.ex @@ -0,0 +1,102 @@ +defmodule ACPRuntime.Local do + @moduledoc "Starts ACP sessions backed by local operating-system processes." + + alias ACPRuntime.{LocalCredentialProvider, LocalProcessRunner, MemoryPersistence, Session} + + def start_session(opts) do + with {:ok, executable} <- required_binary(opts, :executable), + {:ok, args} <- string_list(opts, :args, []), + {:ok, working_directory} <- working_directory(opts), + {:ok, environment} <- string_map(opts, :environment, %{}) do + id = session_id() + agent_name = Keyword.get(opts, :agent_name, Path.basename(executable)) + + agent = %{id: "agent-#{id}", name: agent_name, executable: executable, args: args} + target = %{id: "local", status: "ready"} + + session = %{ + id: id, + status: "connecting", + started_at: DateTime.utc_now(:second), + remote_session_id: nil, + acp_session_id: nil, + last_error: nil, + agent: agent, + environment: target + } + + context = %{environment: environment} + + runtime_opts = [ + registry: ACPRuntime.Registry, + session_supervisor: ACPRuntime.SessionSupervisor, + task_supervisor: ACPRuntime.TaskSupervisor, + process_runner: LocalProcessRunner, + credential_provider: LocalCredentialProvider, + persistence: MemoryPersistence, + notifier: {ACPRuntime.PubSubNotifier, pubsub: ACPRuntime.PubSub}, + working_directory: working_directory, + client_options: [ + supervisor: ACPRuntime.SessionSupervisor, + process_runner: LocalProcessRunner, + client_info: %{ + "name" => "acp_runtime_web", + "title" => "ACP Runtime Web", + "version" => Application.spec(:acp_runtime, :vsn) |> to_string() + } + ] + ] + + with {:ok, pid} <- Session.ensure_started(context, session, runtime_opts) do + {:ok, %{session: session, pid: pid, session_state: Session.state(pid)}} + end + end + end + + def subscribe(session_id) when is_binary(session_id) do + Phoenix.PubSub.subscribe(ACPRuntime.PubSub, ACPRuntime.PubSubNotifier.topic(session_id)) + end + + defp required_binary(opts, key) do + case Keyword.get(opts, key) do + value when is_binary(value) and value != "" -> {:ok, value} + _other -> {:error, {:invalid_option, key}} + end + end + + defp string_list(opts, key, default) do + case Keyword.get(opts, key, default) do + values when is_list(values) -> + if Enum.all?(values, &is_binary/1), + do: {:ok, values}, + else: {:error, {:invalid_option, key}} + + _other -> + {:error, {:invalid_option, key}} + end + end + + defp string_map(opts, key, default) do + case Keyword.get(opts, key, default) do + values when is_map(values) -> + if Enum.all?(values, fn {name, value} -> is_binary(name) and is_binary(value) end), + do: {:ok, values}, + else: {:error, {:invalid_option, key}} + + _other -> + {:error, {:invalid_option, key}} + end + end + + defp working_directory(opts) do + directory = opts |> Keyword.get(:working_directory, File.cwd!()) |> Path.expand() + + if File.dir?(directory), + do: {:ok, directory}, + else: {:error, {:invalid_option, :working_directory}} + end + + defp session_id do + "session-" <> (:crypto.strong_rand_bytes(18) |> Base.url_encode64(padding: false)) + end +end diff --git a/lib/acp_runtime/local_credential_provider.ex b/lib/acp_runtime/local_credential_provider.ex new file mode 100644 index 0000000..9fb76a6 --- /dev/null +++ b/lib/acp_runtime/local_credential_provider.ex @@ -0,0 +1,7 @@ +defmodule ACPRuntime.LocalCredentialProvider do + @moduledoc false + + def credential_env(%{environment: environment}, _agent) when is_map(environment) do + {:ok, environment} + end +end diff --git a/lib/acp_runtime/local_process.ex b/lib/acp_runtime/local_process.ex new file mode 100644 index 0000000..562b4a6 --- /dev/null +++ b/lib/acp_runtime/local_process.ex @@ -0,0 +1,158 @@ +defmodule ACPRuntime.LocalProcess do + @moduledoc false + + use GenServer + + def child_spec(opts) do + %{ + id: {__MODULE__, Keyword.fetch!(opts, :session_id)}, + start: {__MODULE__, :start_link, [opts]}, + restart: :temporary + } + end + + def start_link(opts) do + session_id = Keyword.fetch!(opts, :session_id) + + GenServer.start_link(__MODULE__, opts, + name: {:via, Registry, {ACPRuntime.LocalProcessRegistry, session_id}} + ) + end + + def process(pid), do: GenServer.call(pid, :process) + def write(pid, data), do: GenServer.call(pid, {:write, data}) + def stop(pid), do: GenServer.call(pid, :stop) + + @impl true + def init(opts) do + owner = Keyword.fetch!(opts, :owner) + command = Keyword.fetch!(opts, :command) + working_directory = Keyword.fetch!(opts, :working_directory) + environment = Keyword.get(opts, :environment, %{}) + session_id = Keyword.fetch!(opts, :session_id) + + with :ok <- validate_owner(owner), + :ok <- validate_command(command), + :ok <- validate_working_directory(working_directory), + :ok <- validate_environment(environment), + {:ok, executable} <- resolve_executable(hd(command)), + {:ok, port} <- open_port(executable, tl(command), working_directory, environment) do + Process.monitor(owner) + + process = %{pid: self(), ref: make_ref(), session_id: session_id} + {:ok, %{owner: owner, port: port, process: process, stopping?: false}} + else + {:error, reason} -> {:stop, reason} + end + end + + @impl true + def handle_call(:process, _from, state), do: {:reply, state.process, state} + + def handle_call({:write, data}, _from, state) do + reply = + if Port.info(state.port) do + if Port.command(state.port, data), do: :ok, else: {:error, :closed} + else + {:error, :closed} + end + + {:reply, reply, state} + rescue + ArgumentError -> {:reply, {:error, :closed}, state} + end + + def handle_call(:stop, _from, state) do + close_port(state.port) + {:stop, :normal, :ok, %{state | stopping?: true}} + end + + @impl true + def handle_info({port, {:data, data}}, %{port: port} = state) do + send(state.owner, {:stdout, state.process, data}) + {:noreply, state} + end + + def handle_info({port, {:exit_status, status}}, %{port: port, stopping?: false} = state) do + send(state.owner, {:exit, state.process, status}) + {:stop, :normal, state} + end + + def handle_info({port, {:exit_status, _status}}, %{port: port} = state) do + {:stop, :normal, state} + end + + def handle_info({:DOWN, _ref, :process, owner, _reason}, %{owner: owner} = state) do + close_port(state.port) + {:stop, :normal, %{state | stopping?: true}} + end + + def handle_info(_message, state), do: {:noreply, state} + + @impl true + def terminate(_reason, state) do + close_port(state.port) + :ok + end + + defp open_port(executable, args, working_directory, environment) do + options = [ + :binary, + :exit_status, + :use_stdio, + args: Enum.map(args, &String.to_charlist/1), + cd: String.to_charlist(working_directory) + ] + + options = + if map_size(environment) == 0 do + options + else + env = Enum.map(environment, fn {key, value} -> {to_charlist(key), to_charlist(value)} end) + options ++ [env: env] + end + + {:ok, Port.open({:spawn_executable, String.to_charlist(executable)}, options)} + rescue + error in [ArgumentError, ErlangError] -> + {:error, {:process_start_failed, Exception.message(error)}} + end + + defp resolve_executable(executable) do + case System.find_executable(executable) do + nil -> {:error, {:executable_not_found, executable}} + path -> {:ok, path} + end + end + + defp validate_owner(owner) when is_pid(owner), do: :ok + defp validate_owner(_owner), do: {:error, :invalid_owner} + + defp validate_command([executable | args]) + when is_binary(executable) and executable != "" and is_list(args) do + if Enum.all?(args, &is_binary/1), do: :ok, else: {:error, :invalid_command} + end + + defp validate_command(_command), do: {:error, :invalid_command} + + defp validate_working_directory(directory) when is_binary(directory) do + if File.dir?(directory), do: :ok, else: {:error, {:invalid_working_directory, directory}} + end + + defp validate_working_directory(_directory), do: {:error, :invalid_working_directory} + + defp validate_environment(environment) when is_map(environment) do + if Enum.all?(environment, fn {key, value} -> is_binary(key) and is_binary(value) end), + do: :ok, + else: {:error, :invalid_environment} + end + + defp validate_environment(_environment), do: {:error, :invalid_environment} + + defp close_port(port) do + if Port.info(port), do: Port.close(port) + :ok + rescue + ArgumentError -> :ok + end +end diff --git a/lib/acp_runtime/local_process_runner.ex b/lib/acp_runtime/local_process_runner.ex new file mode 100644 index 0000000..bf87ee0 --- /dev/null +++ b/lib/acp_runtime/local_process_runner.ex @@ -0,0 +1,52 @@ +defmodule ACPRuntime.LocalProcessRunner do + @moduledoc "Runs ACP agent commands as local OS processes." + + @behaviour ACPRuntime.ProcessRunner + + alias ACPRuntime.LocalProcess + + @impl true + def start_process(_context, _target_id, command, opts) do + session_id = local_session_id() + + child_opts = [ + session_id: session_id, + owner: Keyword.fetch!(opts, :owner), + command: command, + working_directory: Keyword.fetch!(opts, :working_directory), + environment: Keyword.get(opts, :environment, %{}) + ] + + with {:ok, pid} <- + DynamicSupervisor.start_child( + ACPRuntime.LocalProcessSupervisor, + {LocalProcess, child_opts} + ) do + {:ok, LocalProcess.process(pid)} + end + end + + @impl true + def process_write(pid, data) when is_pid(pid), do: LocalProcess.write(pid, data) + def process_write(_pid, _data), do: {:error, :invalid_process} + + @impl true + def kill_process(_context, _target_id, session_id) when is_binary(session_id) do + case Registry.lookup(ACPRuntime.LocalProcessRegistry, session_id) do + [{pid, _value}] -> stop_process(pid) + [] -> :ok + end + end + + def kill_process(_context, _target_id, _session_id), do: {:error, :invalid_session_id} + + defp local_session_id do + "local-" <> (:crypto.strong_rand_bytes(18) |> Base.url_encode64(padding: false)) + end + + defp stop_process(pid) do + LocalProcess.stop(pid) + catch + :exit, _reason -> :ok + end +end diff --git a/lib/acp_runtime/memory_persistence.ex b/lib/acp_runtime/memory_persistence.ex new file mode 100644 index 0000000..1e43c26 --- /dev/null +++ b/lib/acp_runtime/memory_persistence.ex @@ -0,0 +1,10 @@ +defmodule ACPRuntime.MemoryPersistence do + @moduledoc false + + @behaviour ACPRuntime.Persistence + + @impl true + def update(session, attrs) when is_map(session) and is_map(attrs) do + {:ok, Map.merge(session, attrs)} + end +end diff --git a/lib/acp_runtime/pub_sub_notifier.ex b/lib/acp_runtime/pub_sub_notifier.ex new file mode 100644 index 0000000..e7995de --- /dev/null +++ b/lib/acp_runtime/pub_sub_notifier.ex @@ -0,0 +1,13 @@ +defmodule ACPRuntime.PubSubNotifier do + @moduledoc false + + @behaviour ACPRuntime.Notifier + + @impl true + def broadcast(opts, session_id, message) do + pubsub = Keyword.fetch!(opts, :pubsub) + Phoenix.PubSub.broadcast(pubsub, topic(session_id), message) + end + + def topic(session_id), do: "acp_runtime_sessions:#{session_id}" +end diff --git a/lib/acp_runtime_web.ex b/lib/acp_runtime_web.ex new file mode 100644 index 0000000..60a8035 --- /dev/null +++ b/lib/acp_runtime_web.ex @@ -0,0 +1,45 @@ +defmodule ACPRuntimeWeb do + @moduledoc false + + def static_paths, do: ~w(assets) + + def router do + quote do + use Phoenix.Router, helpers: false + + import Plug.Conn + import Phoenix.Controller + import Phoenix.LiveView.Router + end + end + + def live_view do + quote do + use Phoenix.LiveView + + alias ACPRuntimeWeb.Layouts + unquote(verified_routes()) + end + end + + def html do + quote do + use Phoenix.Component + + import Phoenix.Controller, only: [get_csrf_token: 0] + alias Phoenix.LiveView.JS + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: ACPRuntimeWeb.Endpoint, + router: ACPRuntimeWeb.Router, + statics: ACPRuntimeWeb.static_paths() + end + end + + defmacro __using__(which) when is_atom(which), do: apply(__MODULE__, which, []) +end diff --git a/lib/acp_runtime_web/components/conversation_actions.ex b/lib/acp_runtime_web/components/conversation_actions.ex new file mode 100644 index 0000000..bc5e114 --- /dev/null +++ b/lib/acp_runtime_web/components/conversation_actions.ex @@ -0,0 +1,13 @@ +defmodule ACPRuntimeWeb.ConversationActions do + @moduledoc "Action contract used by the reusable ACP conversation component." + + @callback prompt(context :: term(), prompt :: String.t()) :: :ok | {:error, term()} + @callback cancel(context :: term()) :: :ok | {:error, term()} + @callback resolve_permission( + context :: term(), + request_id :: integer(), + option_id :: String.t() + ) :: + :ok | {:error, term()} + @callback terminate(context :: term()) :: :ok | {:error, term()} +end diff --git a/lib/acp_runtime_web/components/conversation_component.ex b/lib/acp_runtime_web/components/conversation_component.ex new file mode 100644 index 0000000..3fec5bb --- /dev/null +++ b/lib/acp_runtime_web/components/conversation_component.ex @@ -0,0 +1,484 @@ +defmodule ACPRuntimeWeb.ConversationComponent do + @moduledoc "A reusable LiveComponent for interacting with an ACP runtime session." + + use Phoenix.LiveComponent + + alias ACPRuntimeWeb.Markdown + + @impl true + def mount(socket) do + {:ok, + socket + |> assign(:prompt_form, prompt_form()) + |> assign(:prompt_error, nil) + |> stream_configure(:events, dom_id: & &1.id) + |> stream(:events, [])} + end + + @impl true + def update(%{acp_event: event}, socket) do + {:ok, stream_insert(socket, :events, event)} + end + + def update(%{session_state: session_state} = assigns, socket) + when not is_map_key(assigns, :session_id) do + {:ok, assign(socket, :session_state, session_state)} + end + + def update(assigns, socket) do + previous_session_id = socket.assigns[:session_id] + socket = assign(socket, assigns) + + socket = + if previous_session_id != socket.assigns.session_id do + socket + |> assign(:prompt_form, prompt_form()) + |> assign(:prompt_error, nil) + |> stream(:events, socket.assigns.session_state[:events] || [], reset: true) + else + socket + end + + {:ok, socket} + end + + @impl true + def handle_event("send_prompt", %{"acp_prompt" => %{"prompt" => prompt}}, socket) do + case socket.assigns.action_adapter.prompt(socket.assigns.action_context, prompt) do + :ok -> + {:noreply, + socket + |> assign(:prompt_form, prompt_form()) + |> assign(:prompt_error, nil)} + + {:error, :empty_prompt} -> + {:noreply, assign(socket, :prompt_error, "Enter a prompt first.")} + + {:error, reason} -> + {:noreply, + assign(socket, :prompt_error, "Could not send prompt: #{format_reason(reason)}")} + end + end + + def handle_event("cancel_prompt", _params, socket) do + case socket.assigns.action_adapter.cancel(socket.assigns.action_context) do + :ok -> {:noreply, assign(socket, :prompt_error, nil)} + {:error, reason} -> {:noreply, assign(socket, :prompt_error, format_reason(reason))} + end + end + + def handle_event( + "resolve_permission", + %{"request-id" => request_id, "option-id" => option_id}, + socket + ) do + with {request_id, ""} <- Integer.parse(request_id), + :ok <- + socket.assigns.action_adapter.resolve_permission( + socket.assigns.action_context, + request_id, + option_id + ) do + {:noreply, assign(socket, :prompt_error, nil)} + else + :error -> + {:noreply, assign(socket, :prompt_error, "That permission request is no longer valid.")} + + {:error, reason} -> + {:noreply, assign(socket, :prompt_error, format_reason(reason))} + + {_request_id, _remainder} -> + {:noreply, assign(socket, :prompt_error, "That permission request is no longer valid.")} + end + end + + def handle_event("terminate_session", _params, socket) do + case socket.assigns.action_adapter.terminate(socket.assigns.action_context) do + :ok -> + send(self(), {:acp_session_terminated, socket.assigns.session_id}) + {:noreply, socket} + + {:error, reason} -> + {:noreply, + assign(socket, :prompt_error, "Could not stop session: #{format_reason(reason)}")} + end + end + + @impl true + def render(assigns) do + ~H""" +
+ + + + +
+ +
+

{agent_title(@session_state, @agent_name)}

+
+ + {status_label(@session_state.status)} +
+
+ +
+ + + +
+ {@session_state.prompt_warning.message} +
+ +
+
+ + Your agent is ready + Messages, plans, tool calls, and approvals will appear here. +
+ <.event_card + :for={{dom_id, event} <- @streams.events} + id={dom_id} + event={event} + target={@myself} + /> +
+ +
+

+ {@prompt_error} +

+ <.form + for={@prompt_form} + id="acp-prompt-form" + phx-submit="send_prompt" + phx-target={@myself} + class="acp-composer__form" + > + + + + +
+
+ """ + end + + attr(:id, :string, required: true) + attr(:event, :map, required: true) + attr(:target, :any, required: true) + + defp event_card(assigns) do + ~H""" +
+ <%= case @event.type do %> + <% :user -> %> +
+

{@event.text}

+
+ <% :agent -> %> +
+ {Markdown.to_html(@event.text)} +
+ <% :thought -> %> +
+ Agent reasoning +

{@event.text}

+
+ <% :plan -> %> +
+ Plan +
    +
  • + + {event_value(entry, "content")} +
  • +
+
+ <% :tool -> %> +
+ + {tool_title(@event)} + {event_value(@event, "status") || "running"} + +
{tool_content(@event)}
+
+ <% :permission -> %> +
+ Permission required +

{tool_title(@event.tool_call)}

+
+ +
+

+ {permission_status(@event)} +

+
+ <% :error -> %> +
+

{@event.text}

+
+ <% :log -> %> +
+ Agent diagnostic +
{@event.text}
+
+ <% :turn -> %> +
+ {event_value(@event, "stop_reason") || "Turn complete"} +
+ <% _other -> %> +
Agent update
+ <% end %> +
+ """ + end + + defp prompt_form, do: Phoenix.Component.to_form(%{"prompt" => ""}, as: :acp_prompt) + + defp agent_title(%{agent_info: %{"title" => title}}, _fallback) + when is_binary(title) and title != "", + do: title + + defp agent_title(_state, fallback), do: fallback + + defp status_label(:connecting), do: "Connecting" + defp status_label(:initializing), do: "Initializing" + defp status_label(:creating_session), do: "Creating session" + defp status_label(:ready), do: "Ready" + defp status_label(:prompting), do: "Working" + defp status_label(:error), do: "Needs attention" + defp status_label(:stopped), do: "Stopped" + defp status_label(status), do: status |> to_string() |> String.replace("_", " ") + + defp prompt_placeholder(:ready), do: "Ask the agent to work on this project…" + defp prompt_placeholder(:prompting), do: "The agent is working…" + defp prompt_placeholder(_status), do: "Waiting for the agent to connect…" + + defp event_value(map, key) when is_map(map) do + atom_key = + case key do + "content" -> :content + "label" -> :label + "name" -> :name + "optionId" -> :option_id + "status" -> :status + "stop_reason" -> :stop_reason + "title" -> :title + _other -> nil + end + + Map.get(map, key) || (atom_key && Map.get(map, atom_key)) + end + + defp tool_title(event) do + event_value(event, "title") || event_value(event, "name") || + Map.get(event, "toolCallId") || Map.get(event, :tool_call_id) || "Tool call" + end + + defp tool_content(event) do + event + |> then(&(&1["content"] || &1[:content] || &1["rawOutput"] || &1[:raw_output])) + |> format_tool_content() + end + + defp format_tool_content(nil), do: "" + defp format_tool_content(content) when is_binary(content), do: content + + defp format_tool_content(content) when is_list(content) do + Enum.map_join(content, "\n", &format_tool_content/1) + end + + defp format_tool_content(%{} = block) do + case block["type"] || block[:type] do + "content" -> + format_tool_content(block["content"] || block[:content]) + + "text" -> + format_tool_content(block["text"] || block[:text]) + + "diff" -> + format_diff_content(block) + + "terminal" -> + "Terminal: #{block["terminalId"] || block[:terminal_id] || "unknown"}" + + _other -> + format_structured_content(block) + end + end + + defp format_tool_content(content) when is_integer(content), do: Integer.to_string(content) + defp format_tool_content(content) when is_float(content), do: Float.to_string(content) + defp format_tool_content(content) when is_boolean(content), do: to_string(content) + defp format_tool_content(content), do: inspect(content, pretty: true) + + defp format_diff_content(block) do + path = block["path"] || block[:path] + old_text = block["oldText"] || block[:old_text] + new_text = block["newText"] || block[:new_text] + + [ + if(path, do: "Diff: #{path}"), + if(old_text, do: "--- old\n#{old_text}"), + if(new_text, do: "+++ new\n#{new_text}") + ] + |> Enum.reject(&is_nil/1) + |> Enum.join("\n") + end + + defp format_structured_content(content) do + case Jason.encode(content, pretty: true) do + {:ok, encoded} -> encoded + {:error, _reason} -> inspect(content, pretty: true) + end + end + + defp plan_marker(entry) do + case event_value(entry, "status") do + "completed" -> "✓" + "in_progress" -> "●" + _other -> "○" + end + end + + defp permission_label(option) do + event_value(option, "name") || event_value(option, "label") || + event_value(option, "optionId") || "Continue" + end + + defp permission_status(%{status: :resolved} = event), + do: "Allowed with #{event[:selected] || "the selected option"}." + + defp permission_status(%{status: :cancelled}), do: "Permission request cancelled." + defp permission_status(_event), do: "Permission request completed." + + defp format_reason({:not_ready, status}), do: "the agent is #{status_label(status)}" + defp format_reason(reason), do: inspect(reason) +end diff --git a/lib/acp_runtime_web/components/layouts.ex b/lib/acp_runtime_web/components/layouts.ex new file mode 100644 index 0000000..33edb35 --- /dev/null +++ b/lib/acp_runtime_web/components/layouts.ex @@ -0,0 +1,41 @@ +defmodule ACPRuntimeWeb.Layouts do + @moduledoc false + + use ACPRuntimeWeb, :html + + attr(:flash, :map, required: true) + slot(:inner_block, required: true) + + def app(assigns) do + ~H""" +
+ + {render_slot(@inner_block)} +
+ """ + end + + attr(:inner_content, :any, required: true) + + def root(assigns) do + ~H""" + + + + + + + <.live_title default="ACP Runtime">{assigns[:page_title]} + + + + + {@inner_content} + + + """ + end +end diff --git a/lib/acp_runtime_web/components/session_actions.ex b/lib/acp_runtime_web/components/session_actions.ex new file mode 100644 index 0000000..0cb1b13 --- /dev/null +++ b/lib/acp_runtime_web/components/session_actions.ex @@ -0,0 +1,19 @@ +defmodule ACPRuntimeWeb.SessionActions do + @moduledoc false + + @behaviour ACPRuntimeWeb.ConversationActions + + @impl true + def prompt(session, prompt), do: ACPRuntime.prompt(session, prompt) + + @impl true + def cancel(session), do: ACPRuntime.cancel(session) + + @impl true + def resolve_permission(session, request_id, option_id) do + ACPRuntime.resolve_permission(session, request_id, option_id) + end + + @impl true + def terminate(session), do: ACPRuntime.stop(session) +end diff --git a/lib/acp_runtime_web/controllers/error_html.ex b/lib/acp_runtime_web/controllers/error_html.ex new file mode 100644 index 0000000..a20220c --- /dev/null +++ b/lib/acp_runtime_web/controllers/error_html.ex @@ -0,0 +1,9 @@ +defmodule ACPRuntimeWeb.ErrorHTML do + @moduledoc false + + use ACPRuntimeWeb, :html + + def render(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/lib/acp_runtime_web/endpoint.ex b/lib/acp_runtime_web/endpoint.ex new file mode 100644 index 0000000..08bab53 --- /dev/null +++ b/lib/acp_runtime_web/endpoint.ex @@ -0,0 +1,40 @@ +defmodule ACPRuntimeWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :acp_runtime + + @session_options [ + store: :cookie, + key: "_acp_runtime_key", + signing_salt: "acpRuntimeSession", + same_site: "Lax" + ] + + socket("/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]], + longpoll: false + ) + + plug(Plug.Static, + at: "/", + from: :acp_runtime, + gzip: not code_reloading?, + only: ACPRuntimeWeb.static_paths() + ) + + if code_reloading? do + plug(Phoenix.CodeReloader) + end + + plug(Plug.RequestId) + plug(Plug.Telemetry, event_prefix: [:phoenix, :endpoint]) + + plug(Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + ) + + plug(Plug.MethodOverride) + plug(Plug.Head) + plug(Plug.Session, @session_options) + plug(ACPRuntimeWeb.Router) +end diff --git a/lib/acp_runtime_web/live/local_agent_live.ex b/lib/acp_runtime_web/live/local_agent_live.ex new file mode 100644 index 0000000..b3990bd --- /dev/null +++ b/lib/acp_runtime_web/live/local_agent_live.ex @@ -0,0 +1,239 @@ +defmodule ACPRuntimeWeb.LocalAgentLive do + @moduledoc false + + use ACPRuntimeWeb, :live_view + + alias ACPRuntime.Local + alias ACPRuntimeWeb.{ConversationComponent, SessionActions} + + @impl true + def mount(_params, _session, socket) do + {:ok, + assign(socket, + page_title: "Local ACP Agent", + launch_form: launch_form(), + launch_error: nil, + agent_session: nil, + agent_session_state: nil + )} + end + + @impl true + def handle_event("launch_agent", %{"agent" => params}, socket) do + with {:ok, arguments} <- parse_arguments(params["arguments"]), + {:ok, environment} <- parse_environment(params["environment"]), + {:ok, result} <- + Local.start_session( + executable: String.trim(params["executable"] || ""), + args: arguments, + working_directory: String.trim(params["working_directory"] || ""), + environment: environment + ), + :ok <- Local.subscribe(result.session.id) do + Process.monitor(result.pid) + + {:noreply, + assign(socket, + launch_error: nil, + agent_session: result, + agent_session_state: result.session_state + )} + else + {:error, reason} -> + {:noreply, + socket + |> assign(:launch_form, Phoenix.Component.to_form(params, as: :agent)) + |> assign(:launch_error, format_launch_error(reason))} + end + end + + @impl true + def handle_info( + {:acp_status, state}, + %{assigns: %{agent_session: %{session: session}}} = socket + ) do + if state.session_id == session.id do + send_update(ConversationComponent, id: "acp-conversation", session_state: state) + {:noreply, assign(socket, :agent_session_state, state)} + else + {:noreply, socket} + end + end + + def handle_info({:acp_event, event}, %{assigns: %{agent_session: agent_session}} = socket) + when not is_nil(agent_session) do + send_update(ConversationComponent, id: "acp-conversation", acp_event: event) + {:noreply, socket} + end + + def handle_info({:acp_session_terminated, session_id}, socket) do + case socket.assigns.agent_session do + %{session: %{id: ^session_id}} -> {:noreply, clear_session(socket)} + _other -> {:noreply, socket} + end + end + + def handle_info( + {:DOWN, _ref, :process, pid, :normal}, + %{assigns: %{agent_session: %{pid: pid}}} = socket + ) do + {:noreply, clear_session(socket)} + end + + def handle_info( + {:DOWN, _ref, :process, pid, reason}, + %{assigns: %{agent_session: %{pid: pid}}} = socket + ) do + {:noreply, + socket + |> clear_session() + |> assign(:launch_error, "The ACP session stopped: #{inspect(reason)}")} + end + + def handle_info(_message, socket), do: {:noreply, socket} + + @impl true + def render(assigns) do + ~H""" + +
+
+ ACP Runtime playground +

Launch a local ACP agent

+

+ Start an ACP-compatible command over stdio and exercise the same conversation component used by host applications. +

+
+ + + + <.form + for={@launch_form} + id="local-agent-launch-form" + phx-submit="launch_agent" + class="acp-launcher__form" + > + + + + +

+ The process inherits this server's environment. Additional values entered above override inherited variables. +

+ + +
+ +
+ <.live_component + module={ConversationComponent} + id="acp-conversation" + session_id={@agent_session.session.id} + session_state={@agent_session_state} + agent_name={@agent_session.session.agent.name} + action_adapter={SessionActions} + action_context={@agent_session.pid} + /> +
+
+ """ + end + + defp launch_form do + defaults = Application.get_env(:acp_runtime, :agent_process, []) + + Phoenix.Component.to_form( + %{ + "executable" => Keyword.get(defaults, :executable) || "", + "arguments" => configured_arguments(Keyword.get(defaults, :args)), + "working_directory" => Keyword.get(defaults, :working_directory) || File.cwd!(), + "environment" => configured_environment(Keyword.get(defaults, :environment)) + }, + as: :agent + ) + end + + defp configured_arguments(arguments) when is_list(arguments), + do: Enum.join(arguments, "\n") + + defp configured_arguments(arguments) when is_binary(arguments), do: arguments + + defp configured_arguments(_other), do: "" + + defp configured_environment(environment) when is_map(environment), + do: Enum.map_join(environment, "\n", fn {name, value} -> "#{name}=#{value}" end) + + defp configured_environment(_other), do: "" + + defp parse_arguments(value) when is_binary(value) do + {:ok, split_nonempty_lines(value)} + end + + defp parse_arguments(_value), do: {:error, :invalid_arguments} + + defp parse_environment(value) when is_binary(value) do + Enum.reduce_while(split_nonempty_lines(value), {:ok, %{}}, fn line, {:ok, environment} -> + case String.split(line, "=", parts: 2) do + [name, value] when name != "" -> {:cont, {:ok, Map.put(environment, name, value)}} + _other -> {:halt, {:error, {:invalid_environment_line, line}}} + end + end) + end + + defp parse_environment(_value), do: {:error, :invalid_environment} + + defp split_nonempty_lines(value) do + value + |> String.split(~r/\R/u) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + end + + defp clear_session(socket) do + assign(socket, agent_session: nil, agent_session_state: nil) + end + + defp format_launch_error({:invalid_environment_line, line}), + do: "Invalid environment line: #{line}. Use NAME=value." + + defp format_launch_error({:invalid_option, :executable}), do: "Enter an agent executable." + + defp format_launch_error({:invalid_option, :working_directory}), + do: "Choose an existing working directory." + + defp format_launch_error(reason), do: "Could not launch the ACP agent: #{inspect(reason)}" +end diff --git a/lib/acp_runtime_web/markdown.ex b/lib/acp_runtime_web/markdown.ex new file mode 100644 index 0000000..75fe996 --- /dev/null +++ b/lib/acp_runtime_web/markdown.ex @@ -0,0 +1,77 @@ +defmodule ACPRuntimeWeb.Markdown do + @moduledoc false + + @container_tags ~w(a blockquote code del em h1 h2 h3 h4 h5 h6 li ol p pre strong table tbody td th thead tr ul) + @void_tags ~w(br hr) + + @spec to_html(String.t()) :: Phoenix.HTML.safe() + def to_html(markdown) when is_binary(markdown) do + {_status, ast, _messages} = + EarmarkParser.as_ast(markdown, + code_class_prefix: "language-", + gfm_tables: true, + pure_links: false + ) + + {:safe, render_nodes(ast)} + end + + defp render_nodes(nodes) when is_list(nodes), do: Enum.map(nodes, &render_node/1) + + defp render_node(text) when is_binary(text), do: escape(text) + + defp render_node({tag, attrs, children, _meta}) when tag in @container_tags do + ["<", tag, render_attributes(tag, attrs), ">", render_nodes(children), ""] + end + + defp render_node({tag, attrs, _children, _meta}) when tag in @void_tags do + ["<", tag, render_attributes(tag, attrs), ">"] + end + + # Unknown and raw HTML tags are discarded, while their text remains escaped. + defp render_node({_tag, _attrs, children, _meta}), do: render_nodes(children) + defp render_node(_other), do: "" + + defp render_attributes(tag, attrs) do + attrs + |> Enum.flat_map(&safe_attribute(tag, &1)) + |> Enum.map(fn {name, value} -> [" ", name, "=\"", escape(value), "\""] end) + end + + defp safe_attribute("a", {"href", href}) do + if safe_href?(href), do: [{"href", String.trim(href)}], else: [] + end + + defp safe_attribute("a", {"title", title}), do: [{"title", title}] + + defp safe_attribute("code", {"class", classes}) do + classes = + classes + |> String.split() + |> Enum.filter(&Regex.match?(~r/\Alanguage-[a-zA-Z0-9_+-]+\z/, &1)) + |> Enum.join(" ") + + if classes == "", do: [], else: [{"class", classes}] + end + + defp safe_attribute("ol", {"start", start}) do + if Regex.match?(~r/\A\d+\z/, start), do: [{"start", start}], else: [] + end + + defp safe_attribute(_tag, _attribute), do: [] + + defp safe_href?(href) do + href = String.trim(href) + + not Regex.match?(~r/[\x00-\x20\x7f]/, href) and + case URI.parse(href) do + %URI{scheme: nil} -> true + %URI{scheme: scheme} -> String.downcase(scheme) in ["http", "https", "mailto"] + end + end + + defp escape(value) do + {:safe, escaped} = Phoenix.HTML.html_escape(value) + escaped + end +end diff --git a/lib/acp_runtime_web/router.ex b/lib/acp_runtime_web/router.ex new file mode 100644 index 0000000..9c85ecc --- /dev/null +++ b/lib/acp_runtime_web/router.ex @@ -0,0 +1,18 @@ +defmodule ACPRuntimeWeb.Router do + use ACPRuntimeWeb, :router + + pipeline :browser do + plug(:accepts, ["html"]) + plug(:fetch_session) + plug(:fetch_live_flash) + plug(:put_root_layout, html: {ACPRuntimeWeb.Layouts, :root}) + plug(:protect_from_forgery) + plug(:put_secure_browser_headers) + end + + scope "/", ACPRuntimeWeb do + pipe_through(:browser) + + live("/", LocalAgentLive, :index) + end +end diff --git a/mix.exs b/mix.exs index 8169284..6e9f85f 100644 --- a/mix.exs +++ b/mix.exs @@ -10,6 +10,8 @@ defmodule ACPRuntime.MixProject do version: @version, elixir: "~> 1.17", elixirc_paths: elixirc_paths(Mix.env()), + compilers: [:phoenix_live_view] ++ Mix.compilers(), + listeners: [Phoenix.CodeReloader], start_permanent: Mix.env() == :prod, deps: deps(), description: "Supervised Agent Client Protocol sessions over pluggable process runners", @@ -21,7 +23,7 @@ defmodule ACPRuntime.MixProject do end def application do - [extra_applications: [:logger]] + [mod: {ACPRuntime.Application, []}, extra_applications: [:logger, :runtime_tools]] end def cli do @@ -31,7 +33,14 @@ defmodule ACPRuntime.MixProject do defp deps do [ {:ex_mcp, "~> 0.12.0"}, + {:earmark_parser, "~> 1.4"}, {:jason, "~> 1.4"}, + {:phoenix, "~> 1.8.3"}, + {:phoenix_html, "~> 4.1"}, + {:phoenix_live_view, "~> 1.1"}, + {:bandit, "~> 1.5"}, + {:esbuild, "~> 0.10", runtime: Mix.env() == :dev}, + {:lazy_html, ">= 0.1.0", only: :test}, {:ex_doc, "~> 0.38", only: :dev, runtime: false} ] end @@ -41,6 +50,9 @@ defmodule ACPRuntime.MixProject do defp aliases do [ + setup: ["deps.get", "assets.setup", "assets.build"], + "assets.setup": ["esbuild.install --if-missing"], + "assets.build": ["esbuild acp_runtime"], precommit: ["format --check-formatted", "compile --warnings-as-errors", "test"] ] end diff --git a/priv/static/assets/acp_runtime.css b/priv/static/assets/acp_runtime.css new file mode 100644 index 0000000..28ede12 --- /dev/null +++ b/priv/static/assets/acp_runtime.css @@ -0,0 +1,657 @@ +:root { + --acp-bg: #0b1020; + --acp-surface: #121a2f; + --acp-surface-raised: #19233c; + --acp-border: rgba(148, 163, 184, 0.2); + --acp-text: #edf2ff; + --acp-muted: #9ba8c4; + --acp-primary: #8b7cff; + --acp-primary-strong: #7465f0; + --acp-danger: #fb7185; + --acp-warning: #fbbf24; + --acp-success: #34d399; +} + +body.acp-runtime-standalone, +body.acp-runtime-standalone * { + box-sizing: border-box; +} + +body.acp-runtime-standalone { + min-width: 320px; + min-height: 100vh; + margin: 0; + color-scheme: dark; + color: var(--acp-text); + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: + radial-gradient(circle at 18% 0%, rgba(124, 92, 255, 0.18), transparent 34rem), + radial-gradient(circle at 90% 85%, rgba(45, 212, 191, 0.08), transparent 30rem), + var(--acp-bg); +} + +body.acp-runtime-standalone button, +body.acp-runtime-standalone input, +body.acp-runtime-standalone textarea { + font: inherit; +} + +.acp-page-shell { + width: min(1120px, calc(100% - 2rem)); + min-height: 100vh; + margin: 0 auto; + padding: 2rem 0; +} + +.acp-page-flash { + margin-bottom: 1rem; + padding: 0.8rem 1rem; + border: 1px solid rgba(251, 113, 133, 0.35); + border-radius: 0.8rem; + color: #fecdd3; + background: rgba(159, 18, 57, 0.22); +} + +.acp-workspace { + height: calc(100vh - 4rem); + min-height: 36rem; +} + +.acp-conversation { + display: flex; + min-height: 0; + height: 100%; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--acp-border); + border-radius: 1.15rem; + background: color-mix(in srgb, var(--acp-surface) 94%, transparent); + box-shadow: 0 30px 80px rgba(0, 0, 0, 0.28); +} + +.acp-conversation__header { + display: flex; + align-items: center; + gap: 0.85rem; + padding: 0.9rem 1rem; + border-bottom: 1px solid var(--acp-border); + background: rgba(25, 35, 60, 0.72); +} + +.acp-conversation__mark, +.acp-events__empty-mark { + display: grid; + width: 2.4rem; + height: 2.4rem; + place-items: center; + flex: none; + border-radius: 0.75rem; + color: #c9c2ff; + background: rgba(139, 124, 255, 0.15); +} + +.acp-conversation__heading { + min-width: 0; + flex: 1; +} + +.acp-conversation__title { + overflow: hidden; + margin: 0; + font-size: 0.95rem; + font-weight: 680; + text-overflow: ellipsis; + white-space: nowrap; +} + +.acp-conversation__status { + display: flex; + align-items: center; + gap: 0.45rem; + margin-top: 0.25rem; + color: var(--acp-muted); + font-size: 0.76rem; +} + +.acp-status-dot { + width: 0.48rem; + height: 0.48rem; + border-radius: 999px; + background: var(--acp-muted); +} + +.acp-status-dot[data-status="ready"] { + background: var(--acp-success); +} + +.acp-status-dot[data-status="prompting"] { + background: var(--acp-primary); + animation: acp-pulse 1.2s ease-in-out infinite; +} + +.acp-status-dot[data-status="connecting"], +.acp-status-dot[data-status="initializing"], +.acp-status-dot[data-status="creating_session"] { + background: var(--acp-warning); + animation: acp-pulse 1.2s ease-in-out infinite; +} + +.acp-status-dot[data-status="error"], +.acp-status-dot[data-status="stopped"] { + background: var(--acp-danger); +} + +@keyframes acp-pulse { + 50% { + opacity: 0.38; + transform: scale(0.82); + } +} + +.acp-alert { + margin: 0.8rem 1rem 0; + padding: 0.75rem 0.9rem; + border: 1px solid var(--acp-border); + border-radius: 0.75rem; + font-size: 0.86rem; + line-height: 1.45; +} + +.acp-alert--error { + border-color: rgba(251, 113, 133, 0.32); + color: #fecdd3; + background: rgba(159, 18, 57, 0.2); +} + +.acp-alert--warning { + border-color: rgba(251, 191, 36, 0.3); + color: #fde68a; + background: rgba(146, 64, 14, 0.2); +} + +.acp-events { + min-height: 0; + flex: 1; + overflow-y: auto; + padding: 1.15rem; + scrollbar-color: rgba(148, 163, 184, 0.35) transparent; +} + +.acp-events__empty { + display: none; +} + +.acp-events > .acp-events__empty:only-child { + display: flex; + min-height: 18rem; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 0.65rem; + color: var(--acp-muted); + text-align: center; +} + +.acp-event + .acp-event { + margin-top: 0.85rem; +} + +.acp-event p, +.acp-event pre { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.acp-event__bubble, +.acp-event__panel, +.acp-event__details { + width: fit-content; + max-width: min(52rem, 90%); + border-radius: 1rem; + font-size: 0.9rem; + line-height: 1.55; +} + +.acp-event__bubble { + padding: 0.72rem 0.92rem; +} + +.acp-event__bubble--user { + margin-left: auto; + border-bottom-right-radius: 0.3rem; + color: white; + background: linear-gradient(135deg, var(--acp-primary), var(--acp-primary-strong)); +} + +.acp-event__bubble--agent { + border: 1px solid var(--acp-border); + border-bottom-left-radius: 0.3rem; + background: var(--acp-surface-raised); +} + +.acp-markdown > :first-child { + margin-top: 0; +} + +.acp-markdown > :last-child { + margin-bottom: 0; +} + +.acp-markdown p, +.acp-markdown ul, +.acp-markdown ol, +.acp-markdown blockquote, +.acp-markdown pre, +.acp-markdown table { + margin: 0.7rem 0; +} + +.acp-markdown p { + white-space: normal; +} + +.acp-markdown h1, +.acp-markdown h2, +.acp-markdown h3, +.acp-markdown h4, +.acp-markdown h5, +.acp-markdown h6 { + margin: 1rem 0 0.55rem; + line-height: 1.25; +} + +.acp-markdown h1 { + font-size: 1.45rem; +} + +.acp-markdown h2 { + font-size: 1.25rem; +} + +.acp-markdown h3 { + font-size: 1.1rem; +} + +.acp-markdown h4, +.acp-markdown h5, +.acp-markdown h6 { + font-size: 1rem; +} + +.acp-markdown ul, +.acp-markdown ol { + padding-left: 1.4rem; +} + +.acp-markdown li + li { + margin-top: 0.3rem; +} + +.acp-markdown blockquote { + padding-left: 0.85rem; + border-left: 3px solid rgba(139, 124, 255, 0.5); + color: var(--acp-muted); +} + +.acp-markdown a { + color: #bcb4ff; + text-decoration-thickness: 0.08em; + text-underline-offset: 0.16em; +} + +.acp-markdown code { + padding: 0.12rem 0.32rem; + border-radius: 0.35rem; + color: #e2e8f0; + background: rgba(7, 12, 26, 0.72); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.86em; +} + +.acp-markdown pre { + max-width: 100%; + overflow-x: auto; + padding: 0.8rem; + border: 1px solid var(--acp-border); + border-radius: 0.65rem; + background: rgba(7, 12, 26, 0.72); + white-space: pre; +} + +.acp-markdown pre code { + padding: 0; + background: none; + font-size: 0.82rem; +} + +.acp-markdown table { + width: 100%; + border-collapse: collapse; +} + +.acp-markdown th, +.acp-markdown td { + padding: 0.4rem 0.55rem; + border: 1px solid var(--acp-border); + text-align: left; +} + +.acp-markdown th { + background: rgba(7, 12, 26, 0.42); +} + +.acp-markdown hr { + margin: 1rem 0; + border: 0; + border-top: 1px solid var(--acp-border); +} + +.acp-event__details { + padding: 0.7rem 0.85rem; + border: 1px solid var(--acp-border); + color: #cbd5e1; + background: rgba(25, 35, 60, 0.65); +} + +.acp-event__details summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + cursor: pointer; + color: var(--acp-muted); + font-size: 0.8rem; + font-weight: 650; +} + +.acp-event__details p, +.acp-event__details pre { + margin-top: 0.65rem; +} + +.acp-event__panel { + padding: 0.85rem 0.95rem; + border: 1px solid var(--acp-border); +} + +.acp-event__panel--plan { + border-color: rgba(139, 124, 255, 0.32); + background: rgba(76, 29, 149, 0.18); +} + +.acp-event__panel--plan ul { + display: grid; + gap: 0.35rem; + margin: 0.6rem 0 0; + padding: 0; + list-style: none; +} + +.acp-event__panel--plan li { + display: flex; + gap: 0.55rem; +} + +.acp-event__panel--permission { + border-color: rgba(251, 191, 36, 0.34); + background: rgba(146, 64, 14, 0.17); +} + +.acp-event__panel--permission > p { + margin-top: 0.35rem; + color: #fde68a; +} + +.acp-event__panel--error { + border-color: rgba(251, 113, 133, 0.34); + color: #fecdd3; + background: rgba(159, 18, 57, 0.2); +} + +.acp-event__actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.8rem; +} + +.acp-event__badge { + padding: 0.15rem 0.5rem; + border-radius: 99px; + color: var(--acp-muted); + background: rgba(148, 163, 184, 0.12); + font-size: 0.7rem; + font-weight: 500; +} + +.acp-event__resolution { + margin-top: 0.7rem !important; + font-size: 0.76rem; +} + +.acp-event__turn { + display: flex; + align-items: center; + gap: 0.7rem; + color: rgba(155, 168, 196, 0.58); + font-size: 0.68rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.acp-event__turn::before, +.acp-event__turn::after { + height: 1px; + content: ""; + flex: 1; + background: var(--acp-border); +} + +.acp-composer { + padding: 0.85rem; + border-top: 1px solid var(--acp-border); + background: rgba(25, 35, 60, 0.55); +} + +.acp-composer__error { + margin: 0 0 0.55rem; + color: #fda4af; + font-size: 0.78rem; +} + +.acp-composer__form { + display: flex; + align-items: stretch; + gap: 0.65rem; +} + +.acp-composer__input, +.acp-field input, +.acp-field textarea { + width: 100%; + border: 1px solid var(--acp-border); + border-radius: 0.75rem; + outline: none; + color: var(--acp-text); + background: rgba(7, 12, 26, 0.72); + transition: + border-color 150ms ease, + box-shadow 150ms ease, + background 150ms ease; +} + +.acp-composer__input { + min-height: 4.3rem; + padding: 0.7rem 0.8rem; + flex: 1; + resize: vertical; +} + +.acp-composer__input:focus, +.acp-field input:focus, +.acp-field textarea:focus { + border-color: rgba(139, 124, 255, 0.8); + box-shadow: 0 0 0 3px rgba(139, 124, 255, 0.14); + background: rgba(7, 12, 26, 0.92); +} + +.acp-composer__input:disabled { + cursor: not-allowed; + opacity: 0.52; +} + +.acp-button { + display: inline-flex; + min-height: 2.45rem; + align-items: center; + justify-content: center; + padding: 0.55rem 0.85rem; + border: 1px solid transparent; + border-radius: 0.7rem; + cursor: pointer; + font-size: 0.82rem; + font-weight: 680; + transition: + transform 140ms ease, + border-color 140ms ease, + background 140ms ease, + opacity 140ms ease; +} + +.acp-button:hover:not(:disabled) { + transform: translateY(-1px); +} + +.acp-button:disabled { + cursor: not-allowed; + opacity: 0.4; +} + +.acp-button--primary { + color: white; + background: var(--acp-primary-strong); + box-shadow: 0 8px 20px rgba(116, 101, 240, 0.2); +} + +.acp-button--primary:hover:not(:disabled) { + background: var(--acp-primary); +} + +.acp-button--quiet, +.acp-button--permission { + border-color: var(--acp-border); + color: #dbe4f5; + background: rgba(148, 163, 184, 0.07); +} + +.acp-button--quiet:hover:not(:disabled), +.acp-button--permission:hover:not(:disabled) { + border-color: rgba(139, 124, 255, 0.48); + background: rgba(139, 124, 255, 0.12); +} + +.acp-button--danger { + border-color: rgba(251, 113, 133, 0.36); + color: #fecdd3; + background: rgba(159, 18, 57, 0.2); +} + +.acp-launcher { + width: min(46rem, 100%); + margin: clamp(2rem, 8vh, 6rem) auto 0; + overflow: hidden; + border: 1px solid var(--acp-border); + border-radius: 1.25rem; + background: rgba(18, 26, 47, 0.92); + box-shadow: 0 36px 100px rgba(0, 0, 0, 0.32); +} + +.acp-launcher__intro { + padding: clamp(1.4rem, 4vw, 2.4rem); + border-bottom: 1px solid var(--acp-border); + background: linear-gradient(135deg, rgba(139, 124, 255, 0.13), rgba(45, 212, 191, 0.04)); +} + +.acp-launcher__eyebrow { + color: #bcb4ff; + font-size: 0.72rem; + font-weight: 720; + letter-spacing: 0.11em; + text-transform: uppercase; +} + +.acp-launcher h1 { + margin: 0.5rem 0 0; + font-size: clamp(1.7rem, 4vw, 2.5rem); + letter-spacing: -0.035em; +} + +.acp-launcher__intro p { + max-width: 38rem; + margin: 0.75rem 0 0; + color: var(--acp-muted); + line-height: 1.65; +} + +.acp-launcher__form { + display: grid; + gap: 1rem; + padding: clamp(1.4rem, 4vw, 2.4rem); +} + +.acp-field { + display: grid; + gap: 0.42rem; + color: #dbe4f5; + font-size: 0.82rem; + font-weight: 650; +} + +.acp-field small { + color: var(--acp-muted); + font-weight: 480; +} + +.acp-field input, +.acp-field textarea { + padding: 0.72rem 0.8rem; + resize: vertical; +} + +.acp-launcher__note { + margin: -0.2rem 0 0; + color: var(--acp-muted); + font-size: 0.76rem; + line-height: 1.5; +} + +@media (max-width: 640px) { + .acp-page-shell { + width: 100%; + padding: 0; + } + + .acp-workspace { + height: 100vh; + min-height: 32rem; + } + + .acp-conversation, + .acp-launcher { + border-right: 0; + border-left: 0; + border-radius: 0; + } + + .acp-launcher { + margin-top: 0; + } + + .acp-event__bubble, + .acp-event__panel, + .acp-event__details { + max-width: 96%; + } +} diff --git a/test/acp_runtime/local_test.exs b/test/acp_runtime/local_test.exs new file mode 100644 index 0000000..a901885 --- /dev/null +++ b/test/acp_runtime/local_test.exs @@ -0,0 +1,28 @@ +defmodule ACPRuntime.LocalTest do + use ExUnit.Case, async: false + + alias ACPRuntime.{Local, Session} + + test "launches and interacts with an ACP agent through a local process" do + script = Path.expand("../support/fake_acp_agent.sh", __DIR__) + + assert {:ok, %{pid: pid, session: session}} = + Local.start_session( + executable: "/bin/sh", + args: [script], + working_directory: File.cwd!() + ) + + monitor = Process.monitor(pid) + assert :ok = Local.subscribe(session.id) + assert_receive {:acp_status, %{status: :ready}}, 1_000 + + assert :ok = Session.prompt(pid, "Say hello") + assert_receive {:acp_event, %{type: :user, text: "Say hello"}} + assert_receive {:acp_event, %{type: :agent, text: "Hello from the local ACP fixture."}} + assert_receive {:acp_event, %{type: :turn, stop_reason: "end_turn"}} + + assert :ok = Session.stop(pid) + assert_receive {:DOWN, ^monitor, :process, ^pid, :normal} + end +end diff --git a/test/acp_runtime_web/local_agent_live_test.exs b/test/acp_runtime_web/local_agent_live_test.exs new file mode 100644 index 0000000..8b6e3a7 --- /dev/null +++ b/test/acp_runtime_web/local_agent_live_test.exs @@ -0,0 +1,151 @@ +defmodule ACPRuntimeWeb.LocalAgentLiveTest do + use ExUnit.Case, async: false + + import Phoenix.ConnTest + import Phoenix.LiveViewTest + + @endpoint ACPRuntimeWeb.Endpoint + + test "launches a local agent and exercises the shared conversation component" do + script = Path.expand("../support/fake_acp_agent.sh", __DIR__) + {:ok, view, _html} = live(build_conn(), "/") + + assert has_element?(view, "#local-agent-launch-form") + + view + |> form("#local-agent-launch-form", + agent: %{ + executable: "/bin/sh", + arguments: script, + working_directory: File.cwd!(), + environment: "" + } + ) + |> render_submit() + + assert has_element?(view, "#acp-conversation[data-role='acp-conversation']") + + %{pid: session_pid, session: %{id: session_id}} = + :sys.get_state(view.pid).socket.assigns.agent_session + + :ok = ACPRuntime.Local.subscribe(session_id) + + if ACPRuntime.state(session_pid).status != :ready do + assert_receive {:acp_status, %{status: :ready}}, 1_000 + end + + _ = :sys.get_state(view.pid) + assert has_element?(view, "#send-acp-prompt:not([disabled])") + + view + |> form("#acp-prompt-form", acp_prompt: %{prompt: "Exercise the component"}) + |> render_submit() + + assert_receive {:acp_event, %{type: :agent}}, 1_000 + _ = :sys.get_state(view.pid) + + assert has_element?( + view, + "#acp-events [data-event-type='agent']", + "Hello from the local ACP fixture." + ) + + send( + view.pid, + {:acp_event, + %{ + id: "acp-event-markdown", + type: :agent, + text: "## Markdown response\n\nUses **bold text** and `code`." + }} + ) + + _ = :sys.get_state(view.pid) + + assert has_element?( + view, + "#acp-event-markdown .acp-markdown h2", + "Markdown response" + ) + + assert has_element?(view, "#acp-event-markdown .acp-markdown strong", "bold text") + assert has_element?(view, "#acp-event-markdown .acp-markdown code", "code") + + send( + view.pid, + {:acp_event, + %{ + "toolCallId" => "call-1", + "title" => "git log --oneline -5 && ls", + "status" => "in_progress", + "content" => [ + %{ + "type" => "content", + "content" => %{ + "type" => "text", + "text" => "b2028b9 usable with a local cli agent\n" + } + } + ], + id: "acp-event-tool", + type: :tool + }} + ) + + _ = :sys.get_state(view.pid) + + assert has_element?( + view, + "#acp-events [data-event-type='tool'] pre", + "b2028b9 usable with a local cli agent" + ) + + assert has_element?( + view, + "#acp-conversation script[data-phx-runtime-hook='ACPRuntimeWeb.ConversationComponent.KeepOpen']" + ) + + assert has_element?( + view, + "#acp-conversation script[data-phx-runtime-hook='ACPRuntimeWeb.ConversationComponent.SubmitOnEnter']" + ) + + assert has_element?( + view, + "#acp-event-tool-details[phx-hook='ACPRuntimeWeb.ConversationComponent.KeepOpen']" + ) + + assert has_element?( + view, + "#acp-prompt-input[phx-hook='ACPRuntimeWeb.ConversationComponent.SubmitOnEnter']" + ) + + view + |> element("#terminate-acp-session") + |> render_click() + + _ = :sys.get_state(view.pid) + assert has_element?(view, "#local-agent-launch-form") + end + + test "prefills the launch form from :agent_process application config" do + Application.put_env(:acp_runtime, :agent_process, + executable: "opencode", + args: ["acp", "--model", "test"], + working_directory: File.cwd!(), + environment: %{"ACP_TEST_ENV" => "yes"} + ) + + on_exit(fn -> Application.delete_env(:acp_runtime, :agent_process) end) + + {:ok, view, _html} = live(build_conn(), "/") + + assert has_element?(view, "#local-agent-executable[value='opencode']") + + assert view |> element("#local-agent-arguments") |> render() =~ + "acp\n--model\ntest" + + assert has_element?(view, "#local-agent-working-directory[value='#{File.cwd!()}']") + assert view |> element("#local-agent-environment") |> render() =~ "ACP_TEST_ENV=yes" + end +end diff --git a/test/acp_runtime_web/markdown_test.exs b/test/acp_runtime_web/markdown_test.exs new file mode 100644 index 0000000..63195f6 --- /dev/null +++ b/test/acp_runtime_web/markdown_test.exs @@ -0,0 +1,48 @@ +defmodule ACPRuntimeWeb.MarkdownTest do + use ExUnit.Case, async: true + + alias ACPRuntimeWeb.Markdown + + test "renders common Markdown as safe HTML" do + html = + """ + ## Result + + Uses **bold**, `inline code`, and [a link](https://example.com). + + - first + - second + + ```elixir + IO.puts("hello") + ``` + """ + |> Markdown.to_html() + |> Phoenix.HTML.safe_to_string() + + assert html =~ "

Result

" + assert html =~ "bold" + assert html =~ "inline code" + assert html =~ ~s(a link) + assert html =~ "