"""Unit: dashboard's row-formatting + selection helpers (PRD 0019).""" from __future__ import annotations import tempfile import unittest from pathlib import Path from unittest import mock from bot_bottle import supervise from bot_bottle.cli import dashboard class _FakeHomeMixin: def _setup_fake_home(self) -> None: self._tmp = tempfile.TemporaryDirectory(prefix="dashboard-aa-test.") original = supervise.bot_bottle_root def fake_root() -> Path: return Path(self._tmp.name) / ".bot-bottle" supervise.bot_bottle_root = fake_root # type: ignore[assignment] self._restore_home = lambda: setattr(supervise, "bot_bottle_root", original) def _teardown_fake_home(self) -> None: self._restore_home() self._tmp.cleanup() class TestFormatAgentRow(unittest.TestCase): """One-line row formatting for the agents pane (PRD 0019 chunk 2).""" def _agent(self, **overrides) -> dashboard.ActiveAgent: defaults = dict( backend_name="docker", slug="dev-abc12", agent_name="implementer", started_at="2026-05-26T02:55:01+00:00", services=("egress", "git-gate", "pipelock", "supervise"), ) defaults.update(overrides) return dashboard.ActiveAgent(**defaults) def test_renders_slug_name_time_services(self): s = dashboard._format_agent_row(self._agent(), 200) self.assertIn("dev-abc12", s) self.assertIn("implementer", s) self.assertIn("02:55:01", s) self.assertIn("egress,git-gate,pipelock,supervise", s) def test_starting_label_when_no_services(self): # Race window: compose project is up but containers haven't # been picked up by `docker ps` yet. s = dashboard._format_agent_row(self._agent(services=()), 200) self.assertIn("(starting)", s) def test_filters_agent_service_from_display(self): # The `agent` service is always present for an active bottle; # listing it is noise. The row should show only the sidecars. s = dashboard._format_agent_row( self._agent(services=("agent", "pipelock", "supervise")), 200, ) self.assertIn("[pipelock,supervise]", s) self.assertNotIn("agent,", s) self.assertNotIn(",agent", s) def test_only_agent_service_shows_starting(self): # A bottle whose only running service is `agent` (sidecars # still warming up) renders as `(starting)`. s = dashboard._format_agent_row(self._agent(services=("agent",)), 200) self.assertIn("(starting)", s) def test_question_mark_when_no_started_at(self): s = dashboard._format_agent_row(self._agent(started_at=""), 200) self.assertIn("started ?", s) def test_truncates_to_maxw(self): s = dashboard._format_agent_row(self._agent(), 30) self.assertLessEqual(len(s), 30) self.assertTrue(s.endswith("…")) class TestSelectionStatus(unittest.TestCase): """Idle-state status-line text for the agents-pane focus (PRD 0019 chunk 3). Empty when the proposals pane is focused; surfaces the selected agent (or a clear placeholder) when the agents pane is focused.""" def _agent(self, slug: str) -> dashboard.ActiveAgent: return dashboard.ActiveAgent( backend_name="docker", slug=slug, agent_name="x", started_at="", services=(), ) def test_empty_when_proposals_focused(self): s = dashboard._selection_status( dashboard.PANE_PROPOSALS, [self._agent("a-1")], 0, ) self.assertEqual("", s) def test_no_agents_message_when_agents_pane_empty(self): s = dashboard._selection_status(dashboard.PANE_AGENTS, [], 0) self.assertEqual("[no active agents]", s) def test_shows_selected_slug(self): agents = [self._agent("a-1"), self._agent("b-2"), self._agent("c-3")] s = dashboard._selection_status(dashboard.PANE_AGENTS, agents, 1) self.assertEqual("[selected: b-2]", s) def test_out_of_bounds_falls_back_to_no_selection(self): agents = [self._agent("only")] s = dashboard._selection_status(dashboard.PANE_AGENTS, agents, 99) self.assertEqual("[no agent selected]", s) class TestFilterAgents(unittest.TestCase): """Pure-function picker filter (PRD 0020 chunk 2). Curses-free so we can exercise the substring + case-insensitivity rules directly.""" NAMES = ["implementer", "researcher", "triage-bot", "ImplDeluxe"] def test_empty_query_returns_all(self): self.assertEqual(self.NAMES, dashboard._filter_agents("", self.NAMES)) def test_substring_match(self): self.assertEqual( ["implementer", "ImplDeluxe"], dashboard._filter_agents("impl", self.NAMES), ) def test_case_insensitive(self): self.assertEqual( ["implementer", "ImplDeluxe"], dashboard._filter_agents("IMPL", self.NAMES), ) def test_no_match_returns_empty(self): self.assertEqual([], dashboard._filter_agents("zzz", self.NAMES)) def test_preserves_input_order(self): # Filtering should never re-sort; the picker draws in the # order the manifest exposed. out = dashboard._filter_agents("e", ["beta", "alpha", "echo"]) self.assertEqual(["beta", "echo"], out) class TestDashboardManifestLoading(unittest.TestCase): def test_new_agent_flow_empty_manifest_has_no_picker_entries(self): manifest = dashboard.Manifest.from_json_obj({"bottles": {}, "agents": {}}) with mock.patch("bot_bottle.cli.dashboard._picker_modal", return_value=None) as picker: status = dashboard._new_agent_flow( None, manifest, {}, [], tmux_state=None, # type: ignore[arg-type] ) picker.assert_called_once() self.assertEqual([], picker.call_args.args[1]) self.assertIn("no agents configured", status) class TestRunningCounts(unittest.TestCase): """Per-agent running-count surfaced in the picker so the operator sees `(N running)` before picking. Counts come from the dashboard's current `discover_active_agents` snapshot.""" def _agent(self, agent_name: str) -> dashboard.ActiveAgent: return dashboard.ActiveAgent( backend_name="docker", slug=f"{agent_name}-abc", agent_name=agent_name, started_at="", services=(), ) def test_empty_when_no_active_agents(self): self.assertEqual({}, dashboard._running_counts({}, [])) def test_one_per_unique_agent_name(self): agents = [self._agent("a"), self._agent("b"), self._agent("c")] self.assertEqual( {"a": 1, "b": 1, "c": 1}, dashboard._running_counts({}, agents), ) def test_counts_collisions(self): agents = [ self._agent("implementer"), self._agent("implementer"), self._agent("researcher"), ] self.assertEqual( {"implementer": 2, "researcher": 1}, dashboard._running_counts({}, agents), ) class TestSelectedAgent(unittest.TestCase): """`_selected_agent` is what chunk 4's e/p key handlers use to decide whether to fire and which agent to target.""" def _agent(self, slug: str, services: tuple[str, ...] = ()) -> dashboard.ActiveAgent: return dashboard.ActiveAgent( backend_name="docker", slug=slug, agent_name="x", started_at="", services=services, ) def test_none_when_proposals_focused(self): agents = [self._agent("a-1")] self.assertIsNone( dashboard._selected_agent(dashboard.PANE_PROPOSALS, agents, 0), ) def test_none_when_no_agents(self): self.assertIsNone( dashboard._selected_agent(dashboard.PANE_AGENTS, [], 0), ) def test_returns_indexed_agent_when_in_range(self): agents = [self._agent("a-1"), self._agent("b-2")] result = dashboard._selected_agent(dashboard.PANE_AGENTS, agents, 1) self.assertIsNotNone(result) assert result is not None # for type checker self.assertEqual("b-2", result.slug) def test_none_when_index_out_of_range(self): agents = [self._agent("only")] self.assertIsNone( dashboard._selected_agent(dashboard.PANE_AGENTS, agents, 99), ) class TestBottleForSlug(unittest.TestCase): """Re-attach target resolution (PRD 0020 chunk 3). Dashboard- owned bottles return the stored handle as-is; non-owned bottles get a synthesized DockerBottle backed by the slug-derived container name.""" def test_owned_bottle_returns_held_handle(self): sentinel = object() bottles = {"dev-abc": (None, sentinel, "dev-abc")} bottle, _ = dashboard._bottle_for_slug("dev-abc", bottles, None) self.assertIs(sentinel, bottle) def test_unowned_synthesizes_docker_bottle(self): bottle, _ = dashboard._bottle_for_slug("dev-xyz", {}, None) # The synth wraps the slug-derived container name. self.assertEqual("bot-bottle-dev-xyz", bottle.name) def test_unowned_without_manifest_omits_prompt_path(self): bottle, hint = dashboard._bottle_for_slug("dev-xyz", {}, None) self.assertEqual("", hint) class TestPickNextAfterStop(unittest.TestCase): """After `x` stops a bottle, the dashboard slides focus to the next agent — the one filling the stopped row, or the new last row if the stopped was last. Pure helper, easy to unit-test.""" def _agent(self, slug: str) -> dashboard.ActiveAgent: return dashboard.ActiveAgent( backend_name="docker", slug=slug, agent_name=slug, started_at="", services=(), ) def test_empty_list_returns_none(self): self.assertIsNone( dashboard._pick_next_after_stop([], 0, "anything"), ) def test_only_agent_being_stopped_returns_none(self): # Stopping the last agent → nothing to focus. agents = [self._agent("only")] self.assertIsNone( dashboard._pick_next_after_stop(agents, 0, "only"), ) def test_middle_row_slides_up_to_same_index(self): agents = [self._agent("a"), self._agent("b"), self._agent("c")] # Cursor was on "b" at index 1; stopping "b" → "c" now sits # at index 1 and takes focus. out = dashboard._pick_next_after_stop(agents, 1, "b") self.assertEqual((1, self._agent("c")), out) def test_last_row_wraps_to_new_last(self): agents = [self._agent("a"), self._agent("b"), self._agent("c")] # Cursor on "c" at index 2; stopping "c" leaves a 2-agent # list — index 2 is out of bounds, fall back to new last (1). out = dashboard._pick_next_after_stop(agents, 2, "c") self.assertEqual((1, self._agent("b")), out) def test_first_row(self): agents = [self._agent("a"), self._agent("b")] out = dashboard._pick_next_after_stop(agents, 0, "a") self.assertEqual((0, self._agent("b")), out) def test_clamps_negative_selection(self): # Defensive: a stale negative index doesn't crash. agents = [self._agent("a"), self._agent("b")] out = dashboard._pick_next_after_stop(agents, -1, "a") self.assertEqual((0, self._agent("b")), out) class TestTmuxPaneArgvBuilders(unittest.TestCase): """Pure argv builders for the tmux split-pane integration (PRD 0021 chunk 2). The subprocess invocation itself is environment-dependent; here we lock the wrapping shape so a regression surfaces in CI without needing a real tmux.""" DOCKER_ARGV = [ "docker", "exec", "-it", "bot-bottle-dev-abc", "claude", "--dangerously-skip-permissions", "--continue", ] def test_split_pane_argv_horizontal_with_pane_id_capture(self): argv = dashboard._build_split_pane_argv(self.DOCKER_ARGV) self.assertEqual( ["tmux", "split-window", "-h", "-P", "-F", "#{pane_id}", *self.DOCKER_ARGV], argv, ) def test_respawn_pane_argv_kills_existing_process(self): argv = dashboard._build_respawn_pane_argv("%12", self.DOCKER_ARGV) self.assertEqual( ["tmux", "respawn-pane", "-k", "-t", "%12", *self.DOCKER_ARGV], argv, ) def test_respawn_pane_argv_threads_pane_id_unmodified(self): # Pane ids contain `%`; make sure we pass them straight # through to `-t` without quoting or substitution surprises. argv = dashboard._build_respawn_pane_argv("%abc.123", ["sh"]) self.assertIn("%abc.123", argv) class TestResumeArgvWithFallback(unittest.TestCase): """The `claude --continue || claude` shell fallback for the tmux re-attach path. Without it, an agent that's been spun up but never typed at crashes the pane on Enter because --continue has no session to resume.""" def _bottle(self, prompt_path: str | None = None): from bot_bottle.backend.docker.bottle import DockerBottle return DockerBottle( container="bot-bottle-dev-abc", teardown=lambda: None, prompt_path_in_container=prompt_path, ) def test_wraps_in_sh_c_with_or_fallback(self): argv = dashboard._build_resume_argv_with_fallback(self._bottle()) # Must end with `sh -c ' --continue || '`. self.assertEqual( ["docker", "exec", "-it", "bot-bottle-dev-abc", "sh", "-c"], argv[:6], ) inner = argv[6] self.assertIn("--continue", inner) self.assertIn("||", inner) # Both branches mention claude. self.assertEqual(2, inner.count("claude")) def test_inner_args_quoted_safely(self): # Paths with spaces would break naive concatenation. bottle = self._bottle("/home/with space/.prompt") argv = dashboard._build_resume_argv_with_fallback(bottle) inner = argv[-1] # shlex.quote should single-quote any token with a space. self.assertIn("'/home/with space/.prompt'", inner) def test_includes_skip_permissions(self): argv = dashboard._build_resume_argv_with_fallback(self._bottle()) self.assertIn("--dangerously-skip-permissions", argv[-1]) def test_includes_prompt_file_flag_when_set(self): bottle = self._bottle("/home/node/.bot-bottle-prompt.txt") argv = dashboard._build_resume_argv_with_fallback(bottle) self.assertIn("--append-system-prompt-file", argv[-1]) self.assertIn("/home/node/.bot-bottle-prompt.txt", argv[-1]) class TestClaudeRuntimeArgs(unittest.TestCase): """The argv passed to `bottle.agent_argv` on each attach. Locked here so the tmux + foreground paths build identical agent invocations.""" def test_default_skip_permissions_only(self): self.assertEqual( ["--dangerously-skip-permissions"], dashboard._agent_runtime_args(resume=False), ) def test_resume_appends_continue(self): self.assertEqual( ["--dangerously-skip-permissions", "--continue"], dashboard._agent_runtime_args(resume=True), ) def test_remote_control(self): args = dashboard._agent_runtime_args( resume=False, remote_control=True, ) self.assertIn("--remote-control", args) class TestStopBottleFlow(unittest.TestCase): """Explicit per-bottle stop (PRD 0020 chunk 4). The non-owned path is the one safe to test without curses + docker — the owned path drives `cm.__exit__` against a real launch context and belongs in integration tests.""" def test_non_owned_returns_cleanup_hint(self): # stdscr is None here on purpose — the non-owned branch # returns before any curses call. msg = dashboard._stop_bottle_flow( stdscr=None, # type: ignore[arg-type] bottles={}, slug="ghost-zzz", ) self.assertIn("not dashboard-owned", msg) self.assertIn("./cli.py cleanup", msg) def test_non_owned_does_not_touch_tmux_state(self): # PRD 0021: a stop on an unknown slug should never clear # the right-pane occupant tracking, even if the slugs # happen to match (defensive — non-owned can't be in the # right pane via the dashboard's normal flow anyway). tmux_state = {"pane_id": "%5", "slug": "live-bbb"} dashboard._stop_bottle_flow( stdscr=None, # type: ignore[arg-type] bottles={}, slug="ghost-zzz", tmux_state=tmux_state, ) self.assertEqual({"pane_id": "%5", "slug": "live-bbb"}, tmux_state) class TestOperatorEditFlowGuards(_FakeHomeMixin, unittest.TestCase): """Chunk-4 contract: the edit flow refuses when the selected agent doesn't have the required sidecar running. The discover- and-prompt scaffolding is gone, so the gating happens here instead of in the key handler.""" def setUp(self) -> None: self._setup_fake_home() def tearDown(self) -> None: self._teardown_fake_home() def _agent(self, services: tuple[str, ...]) -> dashboard.ActiveAgent: return dashboard.ActiveAgent( backend_name="docker", slug="dev-abc12", agent_name="impl", started_at="", services=services, ) def test_routes_edit_refuses_without_egress(self): # Bottle without bottle.egress.routes → no egress sidecar. msg = dashboard._operator_edit_flow( stdscr=None, # type: ignore[arg-type] agent=self._agent(("pipelock", "supervise")), required_service="egress", label="routes", fetch=lambda _: "x", apply=lambda _slug, _content: None, suffix=".yaml", ) self.assertIn("no running egress sidecar", msg) self.assertIn("dev-abc12", msg) def test_pipelock_edit_refuses_when_pipelock_missing(self): # Belt-and-braces — pipelock should always be there, but # the race window between `compose up` and `docker ps` # update is real. msg = dashboard._operator_edit_flow( stdscr=None, # type: ignore[arg-type] agent=self._agent(()), required_service="pipelock", label="pipelock", fetch=lambda _: "x", apply=lambda _slug, _content: None, suffix=".txt", ) self.assertIn("no running pipelock sidecar", msg) if __name__ == "__main__": unittest.main()