test/openagents_web/tool_activity_test.exs

58e6347eeb72 · 6 KB

defmodule OpenAgentsWeb.ToolActivityTest do
  @moduledoc """
  The event-header projection: collapsed titles say what actually ran, every
  derived string is byte-capped, and the expansion helpers stay bounded.
  Owner-directed derivations per tool are pinned here (issue #79).
  """

  use ExUnit.Case, async: true
  alias OpenAgentsWeb.ToolActivity

  defp activity(tool_name, arguments, status \\ "succeeded") do
    raw = if is_nil(arguments), do: nil, else: Jason.encode!(arguments)
    %{tool_name: tool_name, status: status, raw_arguments: raw}
  end

  describe "title/1 derivation per tool" do
    test "computer_run joins the argv as a one-line command string" do
      step =
        activity("computer_run", %{
          "machine_id" => "m1",
          "argv" => ["sed", "-n", "1,60p", "docs/TOOL_RUNTIME.md"]
        })

      assert ToolActivity.title(step) == "sed -n 1,60p docs/TOOL_RUNTIME.md"
    end

    test "computer_agent is agent id plus prompt excerpt" do
      step =
        activity("computer_agent", %{"agent_id" => "claude", "prompt" => "Fix the flaky test"})

      assert ToolActivity.title(step) == "claude: Fix the flaky test"
    end

    test "github_repo_read is read repository/path" do
      step =
        activity("github_repo_read", %{
          "repository" => "OpenAgentsInc/openagents.com",
          "path" => "DESIGN.md",
          "ref" => "main"
        })

      assert ToolActivity.title(step) == "read OpenAgentsInc/openagents.com/DESIGN.md"
    end

    test "conversation_search is search with the quoted query" do
      step = activity("conversation_search", %{"query" => "voice budget"})

      assert ToolActivity.title(step) == ~s(search "voice budget")
    end

    test "self-describing tools keep their human subject even with arguments" do
      step = activity("computer_probe", %{"machine_id" => "m1"})

      assert ToolActivity.title(step) == "Finished a check of what's installed on your computer"
    end

    test "other tools fall back to the subject sentence plus a bounded key=value excerpt" do
      step = activity("recall_messages", %{"query" => "the plan"}, "running")

      assert ToolActivity.title(step) ==
               "Working on a look back through this conversation · query=the plan"
    end

    test "a historical step with nil raw_arguments falls back to the current subject" do
      step = activity("computer_run", nil, "succeeded")

      assert ToolActivity.title(step) == "Finished a command on your computer"
    end

    test "undecodable raw_arguments fall back to the current subject" do
      step = %{tool_name: "computer_run", status: "failed", raw_arguments: "{not json"}

      assert ToolActivity.title(step) == "Couldn't finish a command on your computer"
    end

    test "titles are one line and byte-capped with the fuller text on the title attribute" do
      long = String.duplicate("a", 400)
      step = activity("computer_run", %{"argv" => ["echo", "one\ntwo", long]})

      title = ToolActivity.title(step)
      attribute = ToolActivity.title_attribute(step)

      refute title =~ "\n"
      assert byte_size(title) <= 200 + byte_size("…")
      assert String.ends_with?(title, "…")
      assert byte_size(attribute) > byte_size(title)
      assert attribute =~ "one two"
    end

    test "byte caps cut on a UTF-8 boundary" do
      step = activity("computer_run", %{"argv" => ["echo", String.duplicate("é", 300)]})

      title = ToolActivity.title(step)

      assert String.valid?(title)
      assert byte_size(title) <= 200 + byte_size("…")
    end
  end

  describe "status_note/1" do
    test "derived titles carry a text status note for non-success outcomes" do
      step = activity("computer_run", %{"argv" => ["false"]}, "failed")

      assert ToolActivity.status_note(step) == "FAILED"
    end

    test "success stays quiet and subject sentences carry their own status" do
      assert ToolActivity.status_note(activity("computer_run", %{"argv" => ["true"]})) == nil
      assert ToolActivity.status_note(activity("computer_probe", %{}, "failed")) == nil
    end
  end

  describe "expansion helpers" do
    test "arguments render as bounded pretty JSON" do
      step = activity("computer_run", %{"argv" => ["ls"]})

      pretty = ToolActivity.arguments_pretty(step)

      assert pretty =~ "\"argv\""
      assert pretty =~ "\"ls\""
    end

    test "payloads are byte-capped" do
      payload = %{"output" => String.duplicate("x", 10_000)}

      pretty = ToolActivity.payload_pretty(payload)

      assert byte_size(pretty) <= 2_048 + byte_size("…")
      assert String.ends_with?(pretty, "…")
    end

    test "empty payloads render nothing" do
      assert ToolActivity.payload_pretty(nil) == nil
      assert ToolActivity.payload_pretty(%{}) == nil
    end

    test "the executor disclosure appears verbatim only for terminal steps" do
      terminal = %{status: "succeeded", executor_disclosure: "Sarah policy worker"}
      running = %{status: "running", executor_disclosure: "Sarah policy worker"}

      assert ToolActivity.executor_detail(terminal) == "EXECUTOR / Sarah policy worker"
      assert ToolActivity.executor_detail(running) == nil
    end

    test "the timeline lists the lifecycle instants that exist" do
      step = %{
        requested_at: ~U[2026-08-17 12:01:02.000000Z],
        started_at: ~U[2026-08-17 12:01:03.000000Z],
        completed_at: nil
      }

      assert ToolActivity.timeline(step) ==
               "REQUESTED 2026-08-17T12:01:02Z · STARTED 2026-08-17T12:01:03Z"

      assert ToolActivity.timeline(%{}) == nil
    end

    test "timestamp/1 prefers the most recent instant" do
      step = %{
        requested_at: ~U[2026-08-17 12:01:02.000000Z],
        started_at: ~U[2026-08-17 12:01:03.000000Z],
        completed_at: ~U[2026-08-17 12:01:09.000000Z]
      }

      assert ToolActivity.timestamp(step) == ~U[2026-08-17 12:01:09.000000Z]
      assert ToolActivity.timestamp(%{}) == nil
    end
  end

  describe "duration/2" do
    test "formats seconds, minutes, and hours" do
      base = ~U[2026-08-17 12:00:00.000000Z]

      assert ToolActivity.duration(base, ~U[2026-08-17 12:00:42.000000Z]) == "42s"
      assert ToolActivity.duration(base, ~U[2026-08-17 12:03:07.000000Z]) == "3m 7s"
      assert ToolActivity.duration(base, ~U[2026-08-17 13:02:00.000000Z]) == "1h 2m"
      assert ToolActivity.duration(nil, base) == nil
    end
  end
end