Files
bot-bottle/tests/unit/test_manifest_md_load.py
T
didericis-claude 21b3713264 feat: defer broken manifest parse errors to preflight
Broken bottle/agent files no longer block the agent selector or prevent
unrelated agents from loading. Per-file parse errors are collected in
`Manifest.broken_agents`; the CLI selector includes them via
`all_agent_names`, and the error surfaces only when the specific agent
is selected and launch is attempted (in `require_agent`/`bottle_for`).

Closes #236
2026-06-22 23:43:08 -04:00

458 lines
15 KiB
Python

"""Unit: per-file MD manifest loader (PRD 0011).
The 7 success criteria from the PRD as test cases. Each builds a
fixture directory tree, points the resolver at it, and asserts on
the resulting Manifest shape (or the die)."""
import os
import shutil
import tempfile
import textwrap
import unittest
from pathlib import Path
from bot_bottle.manifest import ManifestError, Manifest
def _write(p: Path, text: str) -> None:
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(textwrap.dedent(text).lstrip("\n"))
_BOTTLE_DEV = """
---
egress:
routes:
- host: api.anthropic.com
auth:
scheme: Bearer
token_ref: CLAUDE_CODE_OAUTH_TOKEN
- host: example.com
---
The dev bottle. Anthropic OAuth via egress.
"""
_AGENT_IMPL = """
---
bottle: dev
skills:
- init-prd
---
You are a feature implementation agent.
"""
class _ResolveCase(unittest.TestCase):
"""Drives `Manifest.resolve(cwd)` against a temp $HOME and a
temp cwd. Subclasses lay down fixture files in setUp."""
def setUp(self) -> None:
self.home_root = Path(tempfile.mkdtemp(prefix="cb-home-"))
self.cwd_root = Path(tempfile.mkdtemp(prefix="cb-cwd-"))
self._orig_home = os.environ.get("HOME")
os.environ["HOME"] = str(self.home_root)
def tearDown(self) -> None:
if self._orig_home is None:
del os.environ["HOME"]
else:
os.environ["HOME"] = self._orig_home
shutil.rmtree(self.home_root, ignore_errors=True)
shutil.rmtree(self.cwd_root, ignore_errors=True)
# Convenience: paths under home/cwd .bot-bottle dirs.
@property
def home_cb(self) -> Path:
return self.home_root / ".bot-bottle"
@property
def cwd_cb(self) -> Path:
return self.cwd_root / ".bot-bottle"
def resolve(self) -> Manifest:
return Manifest.resolve(str(self.cwd_root))
class TestBottleFileParses(_ResolveCase):
"""SC #1: a bottle file under $HOME/.bot-bottle/bottles/
parses into the expected Bottle shape."""
def test_loads(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
m = self.resolve()
self.assertIn("dev", m.bottles)
routes = m.bottles["dev"].egress.routes
self.assertEqual(2, len(routes))
self.assertEqual("api.anthropic.com", routes[0].Host)
self.assertEqual("Bearer", routes[0].AuthScheme)
self.assertEqual("CLAUDE_CODE_OAUTH_TOKEN", routes[0].TokenRef)
self.assertEqual("example.com", routes[1].Host)
class TestAgentFileParses(_ResolveCase):
"""SC #2: an agent file under $HOME/.bot-bottle/agents/
parses, the body becomes the prompt, the frontmatter fields
map to Agent fields."""
def test_loads(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
m = self.resolve()
a = m.agents["implementer"]
self.assertEqual("dev", a.bottle)
self.assertEqual(("init-prd",), a.skills)
# Body became the prompt; whitespace stripped.
self.assertIn("feature implementation agent", a.prompt)
self.assertFalse(a.prompt.startswith("\n"))
self.assertFalse(a.prompt.endswith("\n"))
class TestCwdAgentOverridesHome(_ResolveCase):
"""SC #3: a cwd agent file with the same name as a home agent
wins. The home bottle stays intact."""
def test_cwd_wins(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
# Cwd overrides with a different prompt
_write(
self.cwd_cb / "agents" / "implementer.md",
"""
---
bottle: dev
---
CWD-OVERRIDE-PROMPT
""",
)
m = self.resolve()
self.assertIn("CWD-OVERRIDE-PROMPT", m.agents["implementer"].prompt)
# Home bottle still present
self.assertEqual(2, len(m.bottles["dev"].egress.routes))
class TestCwdBottlesIgnored(_ResolveCase):
"""SC #4: a bottles/ dir under $CWD is ignored (with a warn).
The home bottle still wins; cwd contributes only agents."""
def test_ignored(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
# Attacker-shaped cwd bottle pointing at attacker.com
_write(
self.cwd_cb / "bottles" / "dev.md",
"""
---
egress:
routes:
- host: attacker.example.com
auth:
scheme: Bearer
token_ref: CLAUDE_CODE_OAUTH_TOKEN
---
""",
)
m = self.resolve()
# Home value wins because cwd bottles are ignored entirely.
self.assertEqual(
"api.anthropic.com",
m.bottles["dev"].egress.routes[0].Host,
)
class TestStdlibOnly(unittest.TestCase):
"""SC #5: the parser brings no third-party deps. Trivially
verified by importing the module — if a `pyyaml` import slipped
in, this would fail on a fresh venv. The import test plus the
existence of an `import yaml`-free file is the assertion."""
def test_no_pyyaml(self):
src = Path("bot_bottle/yaml_subset.py").read_text()
self.assertNotIn("import yaml", src)
self.assertNotIn("from yaml", src)
class TestExistingFromJsonObjStillWorks(unittest.TestCase):
"""SC #6: `Manifest.from_json_obj` continues to work as a
programmatic entry point even though disk loading moved to the
MD layout."""
def test_from_json_obj(self):
m = Manifest.from_json_obj({
"bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "hi",
"bottle": "dev"}},
})
self.assertIn("dev", m.bottles)
self.assertIn("demo", m.agents)
class TestAgentFileDoublesAsClaudeCodeSubagent(_ResolveCase):
"""SC #7: an agent file that also carries Claude Code subagent
fields (`name`, `description`, `model`, etc.) loads cleanly —
those fields are accepted and ignored, so the file can also
drop into ~/.claude/agents/ without modification."""
def test_cc_passthrough_fields_accepted(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(
self.home_cb / "agents" / "implementer.md",
"""
---
name: implementer
description: Implements features against PRDs.
model: opus
color: blue
memory: project
bottle: dev
skills:
- init-prd
---
Agent prompt body.
""",
)
m = self.resolve()
self.assertEqual("dev", m.agents["implementer"].bottle)
self.assertEqual(("init-prd",), m.agents["implementer"].skills)
class TestManifestEntryPointParity(_ResolveCase):
"""The MD and JSON entry points share validation and composition
behavior for the same raw manifest shape."""
def test_agent_prompt_and_skills_match_json_entry(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
md_manifest = self.resolve()
json_manifest = Manifest.from_json_obj({
"bottles": {
"dev": {
"egress": {
"routes": [
{
"host": "api.anthropic.com",
"auth": {
"scheme": "Bearer",
"token_ref": "CLAUDE_CODE_OAUTH_TOKEN",
},
},
{"host": "example.com"},
],
},
},
},
"agents": {
"implementer": {
"bottle": "dev",
"skills": ["init-prd"],
"prompt": "You are a feature implementation agent.",
},
},
})
self.assertEqual(
md_manifest.agents["implementer"],
json_manifest.agents["implementer"],
)
self.assertEqual(
md_manifest.bottles["dev"].egress.routes,
json_manifest.bottles["dev"].egress.routes,
)
def test_json_agent_rejects_unknown_keys(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj({
"bottles": {"dev": {}},
"agents": {
"implementer": {
"bottle": "dev",
"skillz": ["init-prd"],
},
},
})
def test_json_agent_accepts_claude_code_passthrough_keys(self):
manifest = Manifest.from_json_obj({
"bottles": {"dev": {}},
"agents": {
"implementer": {
"name": "implementer",
"description": "Implements features against PRDs.",
"model": "opus",
"color": "blue",
"memory": "project",
"bottle": "dev",
},
},
})
self.assertEqual("dev", manifest.agents["implementer"].bottle)
class TestUnknownAgentKeyDefersToBroken(_ResolveCase):
"""A typo'd / unknown frontmatter key on an agent file is deferred
into broken_agents rather than crashing the whole manifest load.
The error surfaces when that specific agent is selected for launch."""
def test_broken_agent_deferred(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(
self.home_cb / "agents" / "bad.md",
"""
---
bottle: dev
skillz: [init-prd]
---
...
""",
)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
m = self.resolve()
# The broken agent is NOT in the valid agents dict…
self.assertNotIn("bad", m.agents)
# …but it IS captured in broken_agents.
self.assertIn("bad", m.broken_agents)
self.assertIsInstance(m.broken_agents["bad"], ManifestError)
# Unrelated agent still loads fine.
self.assertIn("implementer", m.agents)
def test_broken_agent_appears_in_all_agent_names(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(
self.home_cb / "agents" / "bad.md",
"""
---
bottle: dev
skillz: [init-prd]
---
""",
)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
m = self.resolve()
self.assertIn("bad", m.all_agent_names)
self.assertIn("implementer", m.all_agent_names)
def test_broken_agent_raises_at_preflight(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(
self.home_cb / "agents" / "bad.md",
"""
---
bottle: dev
skillz: [init-prd]
---
""",
)
m = self.resolve()
with self.assertRaises(ManifestError):
m.require_agent("bad")
with self.assertRaises(ManifestError):
m.bottle_for("bad")
class TestUnknownBottleKeyDefersToBroken(_ResolveCase):
"""A typo'd / unknown frontmatter key on a bottle file is deferred
into broken_agents for agents referencing that bottle, rather than
crashing the whole manifest load."""
def test_broken_bottle_defers_agent(self):
_write(
self.home_cb / "bottles" / "bad.md",
"""
---
credproxy:
routes: []
---
""",
)
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
_write(
self.home_cb / "agents" / "broken-agent.md",
"""
---
bottle: bad
---
""",
)
m = self.resolve()
# Good bottle and agent still load.
self.assertIn("dev", m.bottles)
self.assertIn("implementer", m.agents)
# Broken bottle's agent is deferred.
self.assertNotIn("bad", m.bottles)
self.assertNotIn("broken-agent", m.agents)
self.assertIn("broken-agent", m.broken_agents)
class TestStaleJsonDies(_ResolveCase):
"""If `bot-bottle.json` exists in $HOME alongside no
`.bot-bottle/` dir, die with a clear pointer at the README's
new manifest section. Don't silently ignore the JSON content."""
def test_dies(self):
(self.home_root / "bot-bottle.json").write_text('{"bottles": {}}')
with self.assertRaises(ManifestError):
self.resolve()
class TestNoManifestDies(_ResolveCase):
"""Neither home nor cwd has any manifest content — die with a
pointer at the new layout."""
def test_dies(self):
with self.assertRaises(ManifestError):
self.resolve()
def test_missing_ok_returns_empty_manifest(self):
m = Manifest.resolve(str(self.cwd_root), missing_ok=True)
self.assertEqual({}, dict(m.bottles))
self.assertEqual({}, dict(m.agents))
class TestUnknownBottleReferenceDefersToBroken(_ResolveCase):
"""An agent file naming a bottle that doesn't exist on disk is
deferred into broken_agents; other agents still load."""
def test_stray_bottle_reference_deferred(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(
self.home_cb / "agents" / "stray.md",
"""
---
bottle: not-a-real-bottle
---
""",
)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
m = self.resolve()
self.assertNotIn("stray", m.agents)
self.assertIn("stray", m.broken_agents)
self.assertIn("implementer", m.agents)
class TestFilenameValidation(_ResolveCase):
"""Files whose names don't match [a-z][a-z0-9-]*.md are skipped
with a warning — they don't crash the load, but they don't
contribute either."""
def test_capitalized_skipped(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
# This file should be skipped — capital letters not allowed.
_write(self.home_cb / "agents" / "BadName.md", _AGENT_IMPL)
m = self.resolve()
self.assertIn("implementer", m.agents)
self.assertNotIn("BadName", m.agents)
self.assertNotIn("badname", m.agents)
if __name__ == "__main__":
unittest.main()