lib/openagents_web/components/tool_activity.ex

main at 58e6347eeb72 · 10 KB

defmodule OpenAgentsWeb.ToolActivity do
  @moduledoc """
  UI projection of durable tool-step activity: the collapsed event-header title
  says what actually ran, and the expansion carries the bounded durable details.

  Every rendered value is derived here from the already-scrubbed durable step
  row and byte-capped before it reaches a template, which is what keeps
  INVARIANTS.md UI-002 honest: bounded scrubbed values, never provider
  identifiers or private recall content.

  Titles per tool, per the owner's direction on issue #79:

    * `computer_run` — the argv joined as a one-line command string.
    * `computer_agent` — `<agent_id>: <prompt excerpt>`.
    * `github_repo_read` — `read <repository>/<path>`.
    * `conversation_search` — `search "<query>"`.
    * Self-describing tools (`computer_probe`, `computer_list`,
      `github_repo_list`, the memory tools, `module_discover`) keep the current
      human subject sentence.
    * Anything else — the current subject sentence plus a bounded `key=value`
      excerpt of the arguments.
    * A historical step with no durable `raw_arguments` falls back to the
      current subject sentence.
  """

  # One line, byte-capped. The collapsed title is glanceable; the title
  # attribute carries more of the same text; payloads in the expansion get a
  # larger but still hard bound.
  @title_cap 200
  @title_attribute_cap 1_000
  @payload_cap 2_048
  @excerpt_cap 120

  # Tools whose current human subject already says what ran.
  @self_describing ~w(
    computer_probe computer_list github_repo_list module_discover
    memory_list memory_search memory_remember memory_forget memory_correct
  )

  @doc "The collapsed event-header title: what actually ran, one bounded line."
  def title(activity), do: build_title(activity, @title_cap)

  @doc "The same derivation at the title-attribute bound, for hover/full text."
  def title_attribute(activity), do: build_title(activity, @title_attribute_cap)

  @doc """
  A short uppercase status word for rows whose derived title no longer carries
  the status sentence. `succeeded` stays quiet; everything else is stated in
  text so color never carries the outcome alone.
  """
  def status_note(%{status: "succeeded"}), do: nil

  def status_note(%{tool_name: tool_name, status: status} = activity) do
    if derived_title(tool_name, decoded_arguments(activity)),
      do: String.upcase(status),
      else: nil
  end

  @doc "The status sentence built from the tool subject, as shipped today."
  def label(%{tool_name: tool_name, status: status}) do
    subject = subject(tool_name)
    titled_subject = upcase_first(subject)

    case status do
      "requested" -> "Getting ready for #{subject}"
      "running" -> "Working on #{subject}"
      "succeeded" -> "Finished #{subject}"
      "failed" -> "Couldn't finish #{subject}"
      "refused" -> "#{titled_subject} wasn't permitted"
      "cancelled" -> "#{titled_subject} was stopped"
      "unavailable" -> "#{titled_subject} isn't available right now"
      "interrupted" -> "#{titled_subject} was interrupted"
    end
  end

  # Each subject is a plain-English noun phrase that reads naturally after
  # "getting ready for", "working on", and "finished", and as a sentence
  # subject. Sarah is always capitalized.
  def subject("recall_messages"), do: "a look back through this conversation"
  def subject("conversation_search"), do: "a search of your past conversations"
  def subject("conversation_read"), do: "a read-through of an earlier conversation"
  def subject("memory_list"), do: "a review of what Sarah remembers about you"
  def subject("memory_search"), do: "a search of what Sarah remembers about you"
  def subject("memory_remember"), do: "a new memory about you"
  def subject("memory_forget"), do: "removal of a memory about you"
  def subject("memory_correct"), do: "a correction to Sarah's memory about you"
  def subject("github_repo_list"), do: "a look through your GitHub repositories"
  def subject("github_repo_read"), do: "a read of a file from GitHub"
  def subject("computer_list"), do: "a check of your connected computers"
  def subject("computer_probe"), do: "a check of what's installed on your computer"
  def subject("computer_run"), do: "a command on your computer"
  def subject("computer_devin"), do: "a Devin coding task on your computer"
  def subject("computer_agent"), do: "a coding agent task on your computer"
  def subject("module_discover"), do: "a check of what Sarah can do"
  def subject("repo_read"), do: "a read of OpenAgents source code"
  def subject("repo_grep"), do: "a search of OpenAgents source code"
  def subject("repo_list"), do: "a listing of the OpenAgents source tree"
  def subject("code_check"), do: "a syntax and compile check of candidate code"
  def subject("repo_edit"), do: "an edit in this job's clone of the OpenAgents repository"
  def subject("repo_write"), do: "a file written in this job's OpenAgents repository clone"

  def subject("repo_commit_push"),
    do: "a commit pushed to this job's branch on the OpenAgents forge"

  def subject(tool_name), do: "the #{humanized_tool(tool_name)} step"

  @doc "Bounded pretty JSON of the step's durable raw arguments, or nil."
  def arguments_pretty(%{raw_arguments: raw} = activity) when is_binary(raw) do
    case decoded_arguments(activity) do
      nil -> bound_block(raw)
      decoded -> decoded |> Jason.encode!(pretty: true) |> bound_block()
    end
  end

  def arguments_pretty(_activity), do: nil

  @doc "Bounded pretty JSON of a durable result/error map, or nil."
  def payload_pretty(payload) when is_map(payload) and map_size(payload) > 0,
    do: payload |> Jason.encode!(pretty: true) |> bound_block()

  def payload_pretty(_payload), do: nil

  @doc "The terminal executor disclosure line, verbatim, for the expansion."
  def executor_detail(%{status: status, executor_disclosure: disclosure})
      when status in ~w(succeeded failed refused cancelled unavailable interrupted) and
             is_binary(disclosure) and disclosure != "" do
    "EXECUTOR / #{disclosure}"
  end

  def executor_detail(_activity), do: nil

  @doc "The step's lifecycle instants as one bounded line."
  def timeline(activity) do
    [
      {"REQUESTED", Map.get(activity, :requested_at)},
      {"STARTED", Map.get(activity, :started_at)},
      {"COMPLETED", Map.get(activity, :completed_at)}
    ]
    |> Enum.filter(fn {_label, at} -> match?(%DateTime{}, at) end)
    |> Enum.map_join(" · ", fn {label, at} ->
      "#{label} #{at |> DateTime.truncate(:second) |> DateTime.to_iso8601()}"
    end)
    |> case do
      "" -> nil
      line -> line
    end
  end

  @doc "The most recent lifecycle instant, for the hover timestamp."
  def timestamp(activity) do
    Map.get(activity, :completed_at) || Map.get(activity, :started_at) ||
      Map.get(activity, :requested_at)
  end

  @doc "Formats a deep-work duration as h/m/s, e.g. `3m 7s`."
  def duration(%DateTime{} = started_at, %DateTime{} = completed_at) do
    seconds = max(DateTime.diff(completed_at, started_at, :second), 0)
    hours = div(seconds, 3600)
    minutes = div(rem(seconds, 3600), 60)
    remainder = rem(seconds, 60)

    cond do
      hours > 0 -> "#{hours}h #{minutes}m"
      minutes > 0 -> "#{minutes}m #{remainder}s"
      true -> "#{remainder}s"
    end
  end

  def duration(_started_at, _completed_at), do: nil

  # ── Title derivation ────────────────────────────────────────────────────────

  defp build_title(%{tool_name: tool_name} = activity, cap) do
    arguments = decoded_arguments(activity)
    derived = derived_title(tool_name, arguments)
    excerpt = if tool_name in @self_describing, do: nil, else: argument_excerpt(arguments)

    cond do
      is_binary(derived) -> bound_line(derived, cap)
      is_binary(excerpt) -> bound_line("#{label(activity)} · #{excerpt}", cap)
      true -> label(activity)
    end
  end

  defp derived_title("computer_run", %{"argv" => argv}) when is_list(argv) and argv != [] do
    if Enum.all?(argv, &is_binary/1), do: Enum.join(argv, " "), else: nil
  end

  defp derived_title("computer_agent", %{"agent_id" => agent_id, "prompt" => prompt})
       when is_binary(agent_id) and is_binary(prompt) do
    "#{agent_id}: #{prompt}"
  end

  defp derived_title("github_repo_read", %{"repository" => repository, "path" => path})
       when is_binary(repository) and is_binary(path) do
    "read #{repository}/#{path}"
  end

  defp derived_title("conversation_search", %{"query" => query}) when is_binary(query) do
    ~s(search "#{query}")
  end

  defp derived_title(_tool_name, _arguments), do: nil

  defp argument_excerpt(arguments) when is_map(arguments) and map_size(arguments) > 0 do
    arguments
    |> Enum.sort_by(&elem(&1, 0))
    |> Enum.map(fn {key, value} -> "#{key}=#{scalar(value)}" end)
    |> Enum.join(", ")
    |> bound_line(@excerpt_cap)
  end

  defp argument_excerpt(_arguments), do: nil

  defp scalar(value) when is_binary(value), do: value
  defp scalar(value) when is_number(value) or is_boolean(value), do: to_string(value)
  defp scalar(value), do: Jason.encode!(value)

  defp decoded_arguments(%{raw_arguments: raw}) when is_binary(raw) do
    case Jason.decode(raw) do
      {:ok, decoded} when is_map(decoded) -> decoded
      _invalid -> nil
    end
  end

  defp decoded_arguments(_activity), do: nil

  # ── Bounding ────────────────────────────────────────────────────────────────

  # One line, byte-capped on a UTF-8 boundary. Rendering scrubbed durable values
  # is sanctioned; rendering them unboundedly is not.
  defp bound_line(text, cap) do
    text
    |> String.replace(~r/\s+/u, " ")
    |> String.trim()
    |> bound_bytes(cap)
  end

  defp bound_block(text), do: bound_bytes(text, @payload_cap)

  defp bound_bytes(text, cap) when byte_size(text) <= cap, do: text

  defp bound_bytes(text, cap) do
    truncated =
      text
      |> String.graphemes()
      |> Enum.reduce_while({0, []}, fn grapheme, {bytes, kept} ->
        next = bytes + byte_size(grapheme)
        if next > cap, do: {:halt, {bytes, kept}}, else: {:cont, {next, [grapheme | kept]}}
      end)
      |> then(fn {_bytes, kept} -> kept |> Enum.reverse() |> Enum.join() end)

    truncated <> "…"
  end

  defp humanized_tool(tool_name) when is_binary(tool_name) do
    cleaned = tool_name |> String.replace(["_", "-", "."], " ") |> String.trim()
    if cleaned == "", do: "requested", else: cleaned
  end

  defp humanized_tool(_tool_name), do: "requested"

  defp upcase_first(<<first::utf8, rest::binary>>),
    do: String.upcase(<<first::utf8>>) <> rest
end