feat(cli): add headless launch mode for orchestrators
test / unit (pull_request) Successful in 47s
test / integration (pull_request) Successful in 16s
test / coverage (pull_request) Successful in 1m4s
lint / lint (push) Successful in 2m0s
test / unit (push) Successful in 48s
test / integration (push) Successful in 18s
test / coverage (push) Successful in 57s
Update Quality Badges / update-badges (push) Successful in 57s
test / unit (pull_request) Successful in 47s
test / integration (pull_request) Successful in 16s
test / coverage (pull_request) Successful in 1m4s
lint / lint (push) Successful in 2m0s
test / unit (push) Successful in 48s
test / integration (push) Successful in 18s
test / coverage (push) Successful in 57s
Update Quality Badges / update-badges (push) Successful in 57s
`--headless` is a non-interactive launch path for `cli.py start`: agent, bottles, label, and color come from flags + manifest defaults with no TUI selectors and no y/N preflight (auto-confirmed via a new `assume_yes` param threaded into the shared `_launch_bottle` core). - `--bottle` (repeatable) defaults to the agent's own `bottle:`; `--label` defaults to the agent name and auto-uniquifies on slug collision; `--color` defaults to none. - `--prompt TEXT` is required in headless mode and is delivered to the agent via a new `headless_prompt(prompt)` method on `AgentProvider`, implemented for claude (`-p`), codex (positional), and pi (`-p`). - The agent still execs on inherited stdio/PTY, so whatever allocates the PTY drives the live session; only the launch chrome is headless. - `--headless --dry-run` previews the resolved plan without launching. Adds unit coverage in tests/unit/test_cli_start_headless.py and headless_prompt tests for each provider. Also stubs headless_prompt on the in-test AgentProvider subclasses so the unit suite collects cleanly. Closes #315. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WL77TgFxKbs3cidGMG9dz7
This commit was merged in pull request #316.
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
"""Unit: `cli.py start --headless` non-interactive launch path.
|
||||
|
||||
Headless is the keystone for orchestrators, CI, and webhook
|
||||
dispatch: agent/bottles/label come from flags + manifest defaults, no
|
||||
TUI selectors fire, and the preflight y/N is auto-confirmed
|
||||
(`assume_yes=True`). All actual launch work is stubbed so no container
|
||||
is created.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import bot_bottle.cli.start as start_mod
|
||||
import bot_bottle.cli.tui as tui_mod
|
||||
from bot_bottle.backend import ActiveAgent
|
||||
from bot_bottle.log import Die
|
||||
from bot_bottle.manifest import ManifestError
|
||||
|
||||
|
||||
def _make_manifest(
|
||||
agent_names: list[str],
|
||||
bottle_names: list[str] | None = None,
|
||||
agent_bottle: str = "",
|
||||
):
|
||||
manifest = MagicMock()
|
||||
manifest.agents = {name: MagicMock(bottle=agent_bottle) for name in agent_names}
|
||||
manifest.all_agent_names = sorted(agent_names)
|
||||
manifest.all_bottle_names = sorted(bottle_names or [])
|
||||
manifest.home_md = None # eager mode so _peek_agent_bottle uses agents dict
|
||||
manifest.require_agent = MagicMock(return_value=None)
|
||||
return manifest
|
||||
|
||||
|
||||
def _active_agent(slug: str) -> ActiveAgent:
|
||||
return ActiveAgent(
|
||||
backend_name="docker",
|
||||
slug=slug,
|
||||
agent_name="demo",
|
||||
started_at="2026-01-01T00:00:00+00:00",
|
||||
services=(),
|
||||
)
|
||||
|
||||
|
||||
class TestCmdStartHeadless(unittest.TestCase):
|
||||
"""Drive `cmd_start --headless` with launch + TUI stubbed out."""
|
||||
|
||||
def setUp(self):
|
||||
self._manifest = _make_manifest(
|
||||
["researcher", "implementer"], ["claude", "dev"], agent_bottle="claude"
|
||||
)
|
||||
patch(
|
||||
"bot_bottle.cli.start.ManifestIndex.resolve",
|
||||
return_value=self._manifest,
|
||||
).start()
|
||||
self._launch_mock = patch(
|
||||
"bot_bottle.cli.start._launch_bottle", return_value=0
|
||||
).start()
|
||||
# No bottles running by default → no label collision.
|
||||
patch(
|
||||
"bot_bottle.cli.start.enumerate_active_agents", return_value=[]
|
||||
).start()
|
||||
# If any TUI picker fires in headless mode, that's a bug.
|
||||
self._agent_picker = patch.object(tui_mod, "filter_select").start()
|
||||
self._bottle_picker = patch.object(tui_mod, "filter_multiselect").start()
|
||||
self._modal = patch.object(tui_mod, "name_color_modal").start()
|
||||
patch.dict(os.environ, {}, clear=False).start()
|
||||
os.environ.pop("BOT_BOTTLE_BACKEND", None)
|
||||
self.addCleanup(patch.stopall)
|
||||
|
||||
def _spec(self):
|
||||
self._launch_mock.assert_called_once()
|
||||
return self._launch_mock.call_args[0][0]
|
||||
|
||||
# -- no TUI in headless --------------------------------------------
|
||||
|
||||
def test_headless_fires_no_pickers(self):
|
||||
rc = start_mod.cmd_start(
|
||||
["--headless", "researcher", "--bottle", "claude", "--prompt", "Do it"]
|
||||
)
|
||||
self.assertEqual(0, rc)
|
||||
self._agent_picker.assert_not_called()
|
||||
self._bottle_picker.assert_not_called()
|
||||
self._modal.assert_not_called()
|
||||
|
||||
def test_headless_assume_yes_forwarded(self):
|
||||
start_mod.cmd_start(
|
||||
["--headless", "researcher", "--bottle", "claude", "--prompt", "Do it"]
|
||||
)
|
||||
self.assertTrue(self._launch_mock.call_args[1]["assume_yes"])
|
||||
|
||||
# -- prompt --------------------------------------------------------
|
||||
|
||||
def test_headless_without_prompt_dies(self):
|
||||
with self.assertRaises(Die):
|
||||
start_mod.cmd_start(["--headless", "researcher", "--bottle", "claude"])
|
||||
self._launch_mock.assert_not_called()
|
||||
|
||||
def test_headless_prompt_forwarded_to_launch(self):
|
||||
start_mod.cmd_start(
|
||||
["--headless", "researcher", "--bottle", "claude",
|
||||
"--prompt", "Implement issue #42"]
|
||||
)
|
||||
self.assertEqual(
|
||||
"Implement issue #42",
|
||||
self._launch_mock.call_args[1]["headless_prompt_text"],
|
||||
)
|
||||
|
||||
# -- bottle resolution ---------------------------------------------
|
||||
|
||||
def test_explicit_bottles_forwarded_in_order(self):
|
||||
start_mod.cmd_start(
|
||||
["--headless", "researcher", "--bottle", "dev", "--bottle", "claude",
|
||||
"--prompt", "Do it"]
|
||||
)
|
||||
self.assertEqual(("dev", "claude"), self._spec().bottle_names)
|
||||
|
||||
def test_omitted_bottle_falls_back_to_agent_default(self):
|
||||
start_mod.cmd_start(["--headless", "implementer", "--prompt", "Do it"])
|
||||
self.assertEqual(("claude",), self._spec().bottle_names)
|
||||
|
||||
def test_no_bottle_and_no_default_dies(self):
|
||||
manifest = _make_manifest(["researcher"], ["claude"], agent_bottle="")
|
||||
with patch(
|
||||
"bot_bottle.cli.start.ManifestIndex.resolve", return_value=manifest
|
||||
):
|
||||
with self.assertRaises(Die):
|
||||
start_mod.cmd_start(
|
||||
["--headless", "researcher", "--prompt", "Do it"]
|
||||
)
|
||||
self._launch_mock.assert_not_called()
|
||||
|
||||
# -- agent resolution ----------------------------------------------
|
||||
|
||||
def test_missing_agent_name_dies(self):
|
||||
with self.assertRaises(Die):
|
||||
start_mod.cmd_start(["--headless"])
|
||||
self._launch_mock.assert_not_called()
|
||||
|
||||
def test_unknown_agent_raises_manifest_error(self):
|
||||
self._manifest.require_agent.side_effect = ManifestError("agent 'x' not defined")
|
||||
with self.assertRaises(ManifestError):
|
||||
start_mod.cmd_start(
|
||||
["--headless", "x", "--bottle", "claude", "--prompt", "Do it"]
|
||||
)
|
||||
self._launch_mock.assert_not_called()
|
||||
|
||||
# -- label / color -------------------------------------------------
|
||||
|
||||
def test_label_defaults_to_agent_name(self):
|
||||
start_mod.cmd_start(
|
||||
["--headless", "researcher", "--bottle", "claude", "--prompt", "Do it"]
|
||||
)
|
||||
self.assertEqual("researcher", self._spec().label)
|
||||
|
||||
def test_explicit_label_and_color_forwarded(self):
|
||||
start_mod.cmd_start(
|
||||
["--headless", "researcher", "--bottle", "claude",
|
||||
"--label", "nightly", "--color", "green", "--prompt", "Do it"]
|
||||
)
|
||||
spec = self._spec()
|
||||
self.assertEqual("nightly", spec.label)
|
||||
self.assertEqual("green", spec.color)
|
||||
|
||||
def test_label_collision_uniquifies(self):
|
||||
with patch(
|
||||
"bot_bottle.cli.start.enumerate_active_agents",
|
||||
return_value=[_active_agent("researcher")],
|
||||
):
|
||||
start_mod.cmd_start(
|
||||
["--headless", "researcher", "--bottle", "claude", "--prompt", "Do it"]
|
||||
)
|
||||
self.assertEqual("researcher-2", self._spec().label)
|
||||
|
||||
# -- backend wiring ------------------------------------------------
|
||||
|
||||
def test_backend_flag_forwarded(self):
|
||||
start_mod.cmd_start(
|
||||
["--headless", "--backend=docker", "researcher", "--bottle", "claude",
|
||||
"--prompt", "Do it"]
|
||||
)
|
||||
self.assertEqual("docker", self._launch_mock.call_args[1]["backend_name"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -343,5 +343,14 @@ class TestClaudeSuperviseMcp(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestClaudeHeadlessPrompt(unittest.TestCase):
|
||||
def test_returns_p_flag_and_prompt(self):
|
||||
self.assertEqual(["-p", "Do the task"], ClaudeAgentProvider().headless_prompt("Do the task"))
|
||||
|
||||
def test_preserves_prompt_text_verbatim(self):
|
||||
text = "Fix issue #42: the widget breaks on empty input"
|
||||
self.assertEqual(["-p", text], ClaudeAgentProvider().headless_prompt(text))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -314,5 +314,14 @@ class TestCodexSuperviseMcp(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestCodexHeadlessPrompt(unittest.TestCase):
|
||||
def test_returns_prompt_as_positional_arg(self):
|
||||
self.assertEqual(["Do the task"], CodexAgentProvider().headless_prompt("Do the task"))
|
||||
|
||||
def test_preserves_prompt_text_verbatim(self):
|
||||
text = "Fix issue #42: the widget breaks on empty input"
|
||||
self.assertEqual([text], CodexAgentProvider().headless_prompt(text))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -223,5 +223,14 @@ class TestPiDockerfile(unittest.TestCase):
|
||||
self.assertIn("chmod 1777 /tmp /var/tmp", dockerfile)
|
||||
|
||||
|
||||
class TestPiHeadlessPrompt(unittest.TestCase):
|
||||
def test_returns_p_flag_and_prompt(self):
|
||||
self.assertEqual(["-p", "Do the task"], PiAgentProvider().headless_prompt("Do the task"))
|
||||
|
||||
def test_preserves_prompt_text_verbatim(self):
|
||||
text = "Fix issue #42: the widget breaks on empty input"
|
||||
self.assertEqual(["-p", text], PiAgentProvider().headless_prompt(text))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -38,6 +38,7 @@ class _Provider(AgentProvider):
|
||||
def provision_prompt(self, plan, bottle): ... # type: ignore[override]
|
||||
def provision(self, plan, bottle): ... # type: ignore[override]
|
||||
def provision_supervise_mcp(self, plan, bottle, supervise_url): ... # type: ignore[override]
|
||||
def headless_prompt(self, prompt): return [] # type: ignore[override]
|
||||
|
||||
|
||||
_PROVIDER = _Provider()
|
||||
|
||||
@@ -49,6 +49,7 @@ class _Provider(AgentProvider):
|
||||
def provision_prompt(self, plan, bottle): ... # type: ignore[override]
|
||||
def provision(self, plan, bottle): ... # type: ignore[override]
|
||||
def provision_supervise_mcp(self, plan, bottle, supervise_url): ... # type: ignore[override]
|
||||
def headless_prompt(self, prompt): return [] # type: ignore[override]
|
||||
|
||||
|
||||
_PROVIDER = _Provider()
|
||||
|
||||
Reference in New Issue
Block a user