From 520e6f545dbb61bac27949b620343a75dde475b2 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 1 Jul 2026 17:11:13 +0000 Subject: [PATCH] feat: expose stable Python API for programmatic bottle orchestration Add bot_bottle/api.py with four public functions the orchestrator uses: start_headless, resume_headless, freeze, and destroy. These let a ProgrammaticBottleRunner call directly into bot_bottle instead of shelling out to the CLI; call sites in lifecycle.py stay unchanged. Key changes: - BottleSpec gains forge_env field for forge sidecar credentials - _launch_bottle returns (slug, exit_code) instead of int so start_headless can return the slug to callers - All four API functions convert Die and non-zero exits to BottleError - 27 new unit tests; existing tests updated for the new return type Co-Authored-By: Claude Sonnet 4.6 --- bot_bottle/__init__.py | 10 + bot_bottle/api.py | 258 ++++++++++++++++++++++++ bot_bottle/backend/__init__.py | 7 +- bot_bottle/cli/resume.py | 3 +- bot_bottle/cli/start.py | 18 +- tests/unit/test_api.py | 267 +++++++++++++++++++++++++ tests/unit/test_cli_resume_headless.py | 2 +- tests/unit/test_cli_start_headless.py | 2 +- tests/unit/test_cli_start_selector.py | 4 +- 9 files changed, 560 insertions(+), 11 deletions(-) create mode 100644 bot_bottle/api.py create mode 100644 tests/unit/test_api.py diff --git a/bot_bottle/__init__.py b/bot_bottle/__init__.py index 011ec29..52ccd80 100644 --- a/bot_bottle/__init__.py +++ b/bot_bottle/__init__.py @@ -1 +1,11 @@ """bot-bottle: Python implementation of the agent container launcher.""" + +from .api import BottleError, destroy, freeze, resume_headless, start_headless + +__all__ = [ + "BottleError", + "destroy", + "freeze", + "resume_headless", + "start_headless", +] diff --git a/bot_bottle/api.py b/bot_bottle/api.py new file mode 100644 index 0000000..a7c4130 --- /dev/null +++ b/bot_bottle/api.py @@ -0,0 +1,258 @@ +"""Public Python API for programmatic bottle orchestration. + +Stable surface for bot-bottle-orchestrator (and other Python callers) to +drive bottles without invoking the CLI as a subprocess. Every function +converts ``Die`` and non-zero agent exit codes to ``BottleError`` so +callers use exception handling rather than inspecting return values. + +The Protocol the orchestrator's ``BottleRunner`` targets looks like:: + + class BottleRunner(Protocol): + def start(self, agent: str, *, prompt: str, ...) -> str: ... + def resume(self, slug: str, *, prompt: str) -> None: ... + def freeze(self, slug: str) -> None: ... + def destroy(self, slug: str) -> None: ... + +A ``SubprocessBottleRunner`` calls ``./cli.py`` for each operation. A +``ProgrammaticBottleRunner`` calls these functions directly; the Protocol +call sites in ``lifecycle.py`` are unchanged. +""" + +from __future__ import annotations + +from typing import Sequence + +from .backend import BottleSpec +from .backend.freeze import CommitCancelled, get_freezer +from .bottle_state import cleanup_state, clear_preserve_marker, read_metadata +from .cli._common import USER_CWD +from .cli.start import _launch_bottle, _peek_agent_bottle, _uniquify_label_headless +from .log import Die +from .manifest import ManifestError, ManifestIndex + + +class BottleError(Exception): + """Raised when a bottle operation fails. + + ``exit_code`` carries the agent process's exit code when the failure is + a non-zero agent exit; 1 for all other failure modes (missing state, + backend errors, etc.).""" + + def __init__(self, message: str, *, exit_code: int = 1) -> None: + super().__init__(message) + self.exit_code = exit_code + + +def start_headless( + agent_name: str, + *, + prompt: str, + bottles: Sequence[str] | None = None, + label: str | None = None, + color: str | None = None, + backend_name: str | None = None, + copy_cwd: bool = False, + forge_env: dict[str, str] | None = None, + user_cwd: str | None = None, +) -> str: + """Launch a new bottle headlessly. Returns the bottle slug. + + ``forge_env`` is passed through to the forge sidecar (not the agent) + when the bottle is forge-targeted; it carries the credentials and + context the sidecar needs to call the forge API. + + Raises ``BottleError`` on configuration errors or if the agent exits + non-zero. The returned slug can be passed to ``freeze()``, + ``resume_headless()``, or ``destroy()`` for subsequent lifecycle + operations.""" + cwd = user_cwd or USER_CWD + try: + manifest = ManifestIndex.resolve(cwd) + manifest.require_agent(agent_name) + except (Die, ManifestError) as exc: + raise BottleError(str(exc)) from exc + + if bottles: + bottle_names: tuple[str, ...] = tuple(bottles) + else: + default_bottle = _peek_agent_bottle(manifest, agent_name) + if not default_bottle: + raise BottleError( + f"agent '{agent_name}' has no default bottle; " + f"pass bottles=[...]" + ) + bottle_names = (default_bottle,) + + spec = BottleSpec( + manifest=manifest, + agent_name=agent_name, + copy_cwd=copy_cwd, + user_cwd=cwd, + label=_uniquify_label_headless(label or agent_name), + color=color or "", + bottle_names=bottle_names, + forge_env=dict(forge_env) if forge_env else {}, + ) + try: + slug, exit_code = _launch_bottle( + spec, + dry_run=False, + backend_name=backend_name, + assume_yes=True, + headless_prompt_text=prompt, + ) + except Die as exc: + raise BottleError(exc.message, exit_code=exc.code) from exc + if exit_code != 0: + raise BottleError( + f"agent exited {exit_code} (slug={slug!r})", exit_code=exit_code + ) + return slug + + +def resume_headless( + slug: str, + *, + prompt: str, + backend_name: str | None = None, + forge_env: dict[str, str] | None = None, +) -> None: + """Resume a frozen bottle headlessly with ``prompt``. + + ``forge_env`` re-supplies forge context for the new session (the + sidecar is relaunched alongside the agent on resume). + + Raises ``BottleError`` on missing state, backend errors, or non-zero + agent exit.""" + metadata = read_metadata(slug) + if metadata is None: + raise BottleError( + f"no state recorded for slug {slug!r}; " + f"check ~/.bot-bottle/state/ or call start_headless() to create a new bottle" + ) + + try: + manifest = ManifestIndex.resolve(metadata.cwd or USER_CWD) + manifest.require_agent(metadata.agent_name) + except (Die, ManifestError) as exc: + raise BottleError(str(exc)) from exc + + spec = BottleSpec( + manifest=manifest, + agent_name=metadata.agent_name, + copy_cwd=metadata.copy_cwd, + user_cwd=metadata.cwd or USER_CWD, + identity=metadata.identity, + bottle_names=tuple(metadata.bottle_names), + forge_env=dict(forge_env) if forge_env else {}, + ) + try: + _, exit_code = _launch_bottle( + spec, + dry_run=False, + backend_name=backend_name or metadata.backend or None, + assume_yes=True, + headless_prompt_text=prompt, + ) + except Die as exc: + raise BottleError(exc.message, exit_code=exc.code) from exc + if exit_code != 0: + raise BottleError( + f"agent exited {exit_code} resuming {slug!r}", exit_code=exit_code + ) + + +def freeze(slug: str, *, backend_name: str | None = None) -> None: + """Freeze the named bottle to a resumable artifact. + + Reads the bottle's backend from its metadata when ``backend_name`` is + not supplied. Raises ``BottleError`` if the freeze fails.""" + metadata = read_metadata(slug) + resolved_backend = backend_name or (metadata.backend if metadata else "") or "docker" + try: + get_freezer(resolved_backend).commit_slug(slug) + except CommitCancelled as exc: + raise BottleError(f"freeze cancelled for {slug!r}") from exc + except Die as exc: + raise BottleError(exc.message, exit_code=exc.code) from exc + + +def destroy(slug: str, *, backend_name: str | None = None) -> None: + """Destroy the named bottle, removing all resources and state. + + Brings down any running resources for ``slug``, then removes the + per-bottle state directory. Idempotent: a slug with no running + resources or no state directory is not an error.""" + metadata = read_metadata(slug) + resolved_backend = backend_name or (metadata.backend if metadata else "") or "docker" + try: + if resolved_backend == "docker": + _destroy_docker(slug) + elif resolved_backend == "smolmachines": + _destroy_smolmachines(slug) + # macos-container: the container is torn down inside the launch + # context manager; no persistent VM survives, so nothing extra is + # needed at destroy time beyond the state-dir removal below. + except Die as exc: + raise BottleError(exc.message, exit_code=exc.code) from exc + clear_preserve_marker(slug) + cleanup_state(slug) + + +# --- backend-specific helpers ----------------------------------------------- + + +def _destroy_docker(slug: str) -> None: + """Best-effort ``docker compose down`` for a Docker bottle. + + No-op when the compose file is absent — the project was already + brought down (normal for a frozen bottle) or was never created.""" + from .backend.docker.compose import ( + compose_down, + compose_file_path, + compose_project_name, + ) + from .bottle_state import bottle_state_dir + + state_dir = bottle_state_dir(slug) + compose_file = compose_file_path(state_dir) + if compose_file.exists(): + compose_down(compose_project_name(slug), compose_file) + + +def _destroy_smolmachines(slug: str) -> None: + """Best-effort stop + delete for a smolmachines bottle. + + Both steps are best-effort: a machine that is already gone does not + cause an error; partial failures are logged as warnings.""" + import subprocess + + from .log import warn + + machine = f"bot-bottle-{slug}" + subprocess.run( + ["smolvm", "machine", "stop", "--name", machine], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + r = subprocess.run( + ["smolvm", "machine", "delete", "-f", machine], + capture_output=True, + text=True, + check=False, + ) + if r.returncode != 0: + warn( + f"smolvm machine delete -f {machine!r} failed " + f"(may already be gone): {(r.stderr or '').strip()}" + ) + + +__all__ = [ + "BottleError", + "destroy", + "freeze", + "resume_headless", + "start_headless", +] diff --git a/bot_bottle/backend/__init__.py b/bot_bottle/backend/__init__.py index caf8a8f..b9027ba 100644 --- a/bot_bottle/backend/__init__.py +++ b/bot_bottle/backend/__init__.py @@ -37,7 +37,7 @@ import shlex import sys from abc import ABC, abstractmethod from contextlib import AbstractContextManager -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Any, Generic, Sequence, TypeVar @@ -75,6 +75,11 @@ class BottleSpec: # Ordered bottle names selected at launch (issue #269). When non-empty # they are merged in order and replace the agent's `bottle:` field. bottle_names: tuple[str, ...] = () + # Forge sidecar env vars (PRD forge-native-integration, chunk 1). + # Passed by the orchestrator at launch time; the forge sidecar reads + # them to connect to Gitea. Empty for non-forge runs. The agent + # process itself does not receive these. + forge_env: dict[str, str] = field(default_factory=dict) @dataclass(frozen=True) diff --git a/bot_bottle/cli/resume.py b/bot_bottle/cli/resume.py index d454593..6aaae28 100644 --- a/bot_bottle/cli/resume.py +++ b/bot_bottle/cli/resume.py @@ -74,10 +74,11 @@ def cmd_resume(argv: list[str]) -> int: bottle_names=tuple(metadata.bottle_names), ) backend_name = metadata.backend or None - return _launch_bottle( + _, rc = _launch_bottle( spec, dry_run=args.dry_run, backend_name=backend_name, assume_yes=args.headless, headless_prompt_text=args.prompt or "", ) + return rc diff --git a/bot_bottle/cli/start.py b/bot_bottle/cli/start.py index 0fcc572..2b95929 100644 --- a/bot_bottle/cli/start.py +++ b/bot_bottle/cli/start.py @@ -144,11 +144,12 @@ def cmd_start(argv: list[str]) -> int: color=color, bottle_names=bottle_names, ) - return _launch_bottle( + _, rc = _launch_bottle( spec, dry_run=dry_run, backend_name=backend_name, ) + return rc # --- Headless launch ----------------------------------------------------- @@ -203,13 +204,14 @@ def _start_headless( color=args.color or "", bottle_names=bottle_names, ) - return _launch_bottle( + _, rc = _launch_bottle( spec, dry_run=dry_run, backend_name=backend_name, assume_yes=True, headless_prompt_text=prompt, ) + return rc def _uniquify_label_headless(label: str) -> str: @@ -497,11 +499,16 @@ def _launch_bottle( backend_name: str | None = None, assume_yes: bool = False, headless_prompt_text: str = "", -) -> int: +) -> tuple[str, int]: """Shared launch core for `start` and `resume`. Builds the plan, prints / dry-runs / prompts as appropriate, brings the bottle up, attaches claude, and prints the resume hint on session end. + Returns ``(slug, exit_code)`` where ``slug`` is the bottle identity + (empty string when the launch was aborted before a slug was minted) + and ``exit_code`` is the agent process's exit code (0 on clean exit + or when launch was aborted before the agent ran). + `assume_yes` skips the interactive y/N confirmation (headless / orchestrator launches), where there is no human at the prompt. @@ -510,6 +517,7 @@ def _launch_bottle( agent receives the initial task without interactive input.""" stage_dir = Path(tempfile.mkdtemp(prefix="bot-bottle-stage.")) identity = "" + exit_code = 0 try: plan, identity = prepare_with_preflight( spec, @@ -520,7 +528,7 @@ def _launch_bottle( backend_name=backend_name, ) if plan is None: - return 0 + return identity, 0 backend = get_bottle_backend(backend_name) with backend.launch(plan) as bottle: @@ -547,7 +555,7 @@ def _launch_bottle( # Ctrl-Cs / OOM kills before cleanup removes the state dir. if agent_provider_template == "claude": capture_claude_session_state(identity, exit_code) - return 0 + return identity, exit_code finally: # PRD 0018 chunk 2: prepare now writes the bottle's bind-mount # sources under state//. If we never reached the diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py new file mode 100644 index 0000000..6160919 --- /dev/null +++ b/tests/unit/test_api.py @@ -0,0 +1,267 @@ +"""Unit: bot_bottle public Python API (bot_bottle/__init__.py surface). + +Covers start_headless, resume_headless, freeze, and destroy — the four +operations the bot-bottle-orchestrator's ProgrammaticBottleRunner uses. +All I/O is stubbed so no container is created. +""" + +from __future__ import annotations + +import unittest +from unittest.mock import MagicMock, patch + +from bot_bottle import BottleError, destroy, freeze, resume_headless, start_headless + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + + +def _make_manifest(agent_name: str = "implementer", bottle_name: str = "claude"): + manifest = MagicMock() + manifest.agents = {agent_name: MagicMock(bottle=bottle_name)} + manifest.all_agent_names = [agent_name] + manifest.all_bottle_names = [bottle_name] + manifest.home_md = None # eager mode — _peek_agent_bottle uses agents dict + manifest.require_agent = MagicMock(return_value=None) + return manifest + + +def _metadata( + slug: str = "implementer-abc12", + agent_name: str = "implementer", + backend: str = "docker", +): + md = MagicMock() + md.identity = slug + md.agent_name = agent_name + md.cwd = "/repo" + md.copy_cwd = False + md.bottle_names = ["claude"] + md.backend = backend + return md + + +# --------------------------------------------------------------------------- +# start_headless +# --------------------------------------------------------------------------- + + +class TestStartHeadless(unittest.TestCase): + def setUp(self) -> None: + self._manifest = _make_manifest() + patch("bot_bottle.api.ManifestIndex.resolve", return_value=self._manifest).start() + self._launch = patch( + "bot_bottle.api._launch_bottle", return_value=("implementer-abc12", 0) + ).start() + patch( + "bot_bottle.api._uniquify_label_headless", side_effect=lambda lbl: lbl + ).start() + self.addCleanup(patch.stopall) + + def _spec(self): + self._launch.assert_called_once() + return self._launch.call_args[0][0] + + def test_returns_slug_on_success(self): + slug = start_headless("implementer", prompt="Do it") + self.assertEqual("implementer-abc12", slug) + + def test_passes_assume_yes_and_prompt(self): + start_headless("implementer", prompt="Do it") + kwargs = self._launch.call_args[1] + self.assertTrue(kwargs["assume_yes"]) + self.assertEqual("Do it", kwargs["headless_prompt_text"]) + + def test_explicit_bottles_forwarded(self): + start_headless("implementer", prompt="Do it", bottles=["dev", "claude"]) + self.assertEqual(("dev", "claude"), self._spec().bottle_names) + + def test_default_bottle_resolved_from_manifest(self): + start_headless("implementer", prompt="Do it") + self.assertEqual(("claude",), self._spec().bottle_names) + + def test_forge_env_on_spec(self): + env = {"FORGE_GITEA_API": "https://gitea.example.com/api/v1", "FORGE_OWNER": "acme"} + start_headless("implementer", prompt="Do it", forge_env=env) + self.assertEqual(env, self._spec().forge_env) + + def test_no_forge_env_defaults_to_empty_dict(self): + start_headless("implementer", prompt="Do it") + self.assertEqual({}, self._spec().forge_env) + + def test_nonzero_exit_raises_bottle_error(self): + self._launch.return_value = ("implementer-abc12", 1) + with self.assertRaises(BottleError) as ctx: + start_headless("implementer", prompt="Do it") + self.assertEqual(1, ctx.exception.exit_code) + + def test_no_default_bottle_raises_bottle_error(self): + manifest = _make_manifest(bottle_name="") + with patch("bot_bottle.api.ManifestIndex.resolve", return_value=manifest): + with self.assertRaises(BottleError): + start_headless("implementer", prompt="Do it") + self._launch.assert_not_called() + + def test_backend_name_forwarded(self): + start_headless("implementer", prompt="Do it", backend_name="docker") + self.assertEqual("docker", self._launch.call_args[1]["backend_name"]) + + def test_label_forwarded_to_spec(self): + start_headless("implementer", prompt="Do it", label="nightly") + self.assertEqual("nightly", self._spec().label) + + def test_color_forwarded_to_spec(self): + start_headless("implementer", prompt="Do it", color="green") + self.assertEqual("green", self._spec().color) + + +# --------------------------------------------------------------------------- +# resume_headless +# --------------------------------------------------------------------------- + + +class TestResumeHeadless(unittest.TestCase): + def setUp(self) -> None: + self._md = _metadata() + patch("bot_bottle.api.read_metadata", return_value=self._md).start() + manifest = _make_manifest() + patch("bot_bottle.api.ManifestIndex.resolve", return_value=manifest).start() + self._launch = patch( + "bot_bottle.api._launch_bottle", return_value=("implementer-abc12", 0) + ).start() + self.addCleanup(patch.stopall) + + def _spec(self): + self._launch.assert_called_once() + return self._launch.call_args[0][0] + + def test_passes_assume_yes_and_prompt(self): + resume_headless("implementer-abc12", prompt="Address review") + kwargs = self._launch.call_args[1] + self.assertTrue(kwargs["assume_yes"]) + self.assertEqual("Address review", kwargs["headless_prompt_text"]) + + def test_identity_set_on_spec(self): + resume_headless("implementer-abc12", prompt="Prompt") + self.assertEqual("implementer-abc12", self._spec().identity) + + def test_forge_env_on_spec(self): + env = {"FORGE_ISSUE_NUMBER": "42"} + resume_headless("implementer-abc12", prompt="Prompt", forge_env=env) + self.assertEqual(env, self._spec().forge_env) + + def test_missing_state_raises_bottle_error(self): + with patch("bot_bottle.api.read_metadata", return_value=None): + with self.assertRaises(BottleError): + resume_headless("no-such-abc12", prompt="Prompt") + self._launch.assert_not_called() + + def test_nonzero_exit_raises_bottle_error(self): + self._launch.return_value = ("implementer-abc12", 2) + with self.assertRaises(BottleError) as ctx: + resume_headless("implementer-abc12", prompt="Prompt") + self.assertEqual(2, ctx.exception.exit_code) + + def test_backend_from_metadata_when_not_supplied(self): + resume_headless("implementer-abc12", prompt="Prompt") + self.assertEqual("docker", self._launch.call_args[1]["backend_name"]) + + def test_explicit_backend_overrides_metadata(self): + resume_headless( + "implementer-abc12", prompt="Prompt", backend_name="smolmachines" + ) + self.assertEqual("smolmachines", self._launch.call_args[1]["backend_name"]) + + +# --------------------------------------------------------------------------- +# freeze +# --------------------------------------------------------------------------- + + +class TestFreeze(unittest.TestCase): + def setUp(self) -> None: + patch("bot_bottle.api.read_metadata", return_value=_metadata()).start() + self._freezer = MagicMock() + self._get_freezer = patch( + "bot_bottle.api.get_freezer", return_value=self._freezer + ).start() + self.addCleanup(patch.stopall) + + def test_calls_commit_slug(self): + freeze("implementer-abc12") + self._freezer.commit_slug.assert_called_once_with("implementer-abc12") + + def test_backend_from_metadata_when_not_supplied(self): + freeze("implementer-abc12") + self._get_freezer.assert_called_once_with("docker") + + def test_explicit_backend_used(self): + freeze("implementer-abc12", backend_name="smolmachines") + self._get_freezer.assert_called_once_with("smolmachines") + + def test_commit_cancelled_raises_bottle_error(self): + from bot_bottle.backend.freeze import CommitCancelled + self._freezer.commit_slug.side_effect = CommitCancelled("declined") + with self.assertRaises(BottleError): + freeze("implementer-abc12") + + +# --------------------------------------------------------------------------- +# destroy +# --------------------------------------------------------------------------- + + +class TestDestroy(unittest.TestCase): + def setUp(self) -> None: + patch("bot_bottle.api.read_metadata", return_value=_metadata()).start() + self._dd = patch("bot_bottle.api._destroy_docker").start() + patch("bot_bottle.api.clear_preserve_marker").start() + self._cleanup = patch("bot_bottle.api.cleanup_state").start() + self.addCleanup(patch.stopall) + + def test_docker_backend_calls_destroy_docker(self): + destroy("implementer-abc12") + self._dd.assert_called_once_with("implementer-abc12") + + def test_state_dir_always_cleaned(self): + destroy("implementer-abc12") + self._cleanup.assert_called_once_with("implementer-abc12") + + def test_smolmachines_backend_calls_destroy_smolmachines(self): + patch( + "bot_bottle.api.read_metadata", + return_value=_metadata(backend="smolmachines"), + ).start() + ds = patch("bot_bottle.api._destroy_smolmachines").start() + destroy("implementer-abc12") + ds.assert_called_once_with("implementer-abc12") + self._dd.assert_not_called() + + def test_missing_metadata_defaults_to_docker(self): + patch("bot_bottle.api.read_metadata", return_value=None).start() + destroy("no-state-abc12") + self._dd.assert_called_once_with("no-state-abc12") + + def test_explicit_backend_overrides_metadata(self): + ds = patch("bot_bottle.api._destroy_smolmachines").start() + destroy("implementer-abc12", backend_name="smolmachines") + ds.assert_called_once_with("implementer-abc12") + self._dd.assert_not_called() + + +# --------------------------------------------------------------------------- +# public surface exported from bot_bottle.__init__ +# --------------------------------------------------------------------------- + + +class TestPublicSurface(unittest.TestCase): + def test_importable_from_package(self): + import bot_bottle + for name in ("BottleError", "start_headless", "resume_headless", "freeze", "destroy"): + self.assertTrue(hasattr(bot_bottle, name), f"missing: {name}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_cli_resume_headless.py b/tests/unit/test_cli_resume_headless.py index bdeb3a4..efc35f1 100644 --- a/tests/unit/test_cli_resume_headless.py +++ b/tests/unit/test_cli_resume_headless.py @@ -29,7 +29,7 @@ def _metadata(): class ResumeHeadlessTest(unittest.TestCase): def setUp(self) -> None: self._launch = patch.object( - resume_mod, "_launch_bottle", return_value=0 + resume_mod, "_launch_bottle", return_value=("implementer-abc12", 0) ).start() patch.object( resume_mod, "read_metadata", return_value=_metadata() diff --git a/tests/unit/test_cli_start_headless.py b/tests/unit/test_cli_start_headless.py index b956452..99e3d7b 100644 --- a/tests/unit/test_cli_start_headless.py +++ b/tests/unit/test_cli_start_headless.py @@ -56,7 +56,7 @@ class TestCmdStartHeadless(unittest.TestCase): return_value=self._manifest, ).start() self._launch_mock = patch( - "bot_bottle.cli.start._launch_bottle", return_value=0 + "bot_bottle.cli.start._launch_bottle", return_value=("", 0) ).start() # No bottles running by default → no label collision. patch( diff --git a/tests/unit/test_cli_start_selector.py b/tests/unit/test_cli_start_selector.py index b091bfb..64b5a15 100644 --- a/tests/unit/test_cli_start_selector.py +++ b/tests/unit/test_cli_start_selector.py @@ -45,7 +45,7 @@ class TestCmdStartSelector(unittest.TestCase): self._launch_patch = patch( "bot_bottle.cli.start._launch_bottle", - return_value=0, + return_value=("", 0), ) self._launch_mock = self._launch_patch.start() @@ -211,7 +211,7 @@ class TestCmdStartLabelCollision(unittest.TestCase): self._manifest = _make_manifest(["researcher"], ["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, + "bot_bottle.cli.start._launch_bottle", return_value=("", 0), ).start() # Stub the bottle picker to always return a selection. patch.object(tui_mod, "filter_multiselect", return_value=["claude"]).start()