234 lines
8.3 KiB
Python
234 lines
8.3 KiB
Python
"""Unit tests for shellbound (no network required)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
from shellbound import modes
|
|
from shellbound.personas import (
|
|
AmbiguousPersona,
|
|
can_expected_mode,
|
|
default_persona_name,
|
|
resolve_mode,
|
|
resolve_name,
|
|
shellbound_personas,
|
|
)
|
|
|
|
RAW = [
|
|
{"name": "shellbound_shell", "applies_to_kinds": ["interactive"]},
|
|
{"name": "shellbound_answer", "applies_to_kinds": ["interactive"]},
|
|
{"name": "shellbound_creative", "applies_to_kinds": ["interactive"]},
|
|
{"name": "shellbound_expert", "applies_to_kinds": ["interactive"]},
|
|
{"name": "shellbound_explain", "applies_to_kinds": ["interactive"]},
|
|
{"name": "shellbound_poetic", "applies_to_kinds": ["interactive"]},
|
|
{"name": "engineer", "applies_to_kinds": ["interactive"], "is_default": True},
|
|
{"name": "orchestrator", "applies_to_kinds": ["coordinator"]},
|
|
]
|
|
|
|
|
|
def test_shellbound_filter():
|
|
slugs = {p["name"] for p in shellbound_personas(RAW)}
|
|
assert slugs == {
|
|
"shellbound_shell",
|
|
"shellbound_answer",
|
|
"shellbound_creative",
|
|
"shellbound_expert",
|
|
"shellbound_explain",
|
|
"shellbound_poetic",
|
|
}
|
|
|
|
|
|
def test_unique_abbreviations():
|
|
assert resolve_mode(RAW, "s")["name"] == "shellbound_shell"
|
|
assert resolve_mode(RAW, "a")["name"] == "shellbound_answer"
|
|
assert resolve_mode(RAW, "c")["name"] == "shellbound_creative"
|
|
assert resolve_mode(RAW, "p")["name"] == "shellbound_poetic"
|
|
assert resolve_mode(RAW, "sh")["name"] == "shellbound_shell"
|
|
assert resolve_mode(RAW, "expe")["name"] == "shellbound_expert"
|
|
assert resolve_mode(RAW, "expl")["name"] == "shellbound_explain"
|
|
assert resolve_mode(RAW, "shellbound_shell")["name"] == "shellbound_shell"
|
|
assert resolve_mode(RAW, "SHELLBOUND_ANSWER")["name"] == "shellbound_answer"
|
|
|
|
|
|
def test_ambiguous_abbreviation():
|
|
try:
|
|
resolve_mode(RAW, "e")
|
|
except AmbiguousPersona as exc:
|
|
assert set(exc.candidates) == {"shellbound_expert", "shellbound_explain"}
|
|
else:
|
|
raise AssertionError("expected AmbiguousPersona")
|
|
|
|
|
|
def test_no_match_is_query():
|
|
assert resolve_mode(RAW, "zzz") is None
|
|
assert can_expected_mode("zzz") is False
|
|
assert can_expected_mode("s") is True
|
|
|
|
|
|
def test_resolve_name():
|
|
assert resolve_name(RAW, "engineer")["name"] == "engineer"
|
|
assert resolve_name(RAW, "shellbound_poetic")["name"] == "shellbound_poetic"
|
|
try:
|
|
resolve_name(RAW, "orchestrator") # coordinator kind, not interactive
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
raise AssertionError("orchestrator should be rejected (non-interactive)")
|
|
|
|
|
|
def test_default_persona():
|
|
assert default_persona_name(RAW) == "shellbound_answer"
|
|
|
|
|
|
def test_extract_commands_fenced():
|
|
raw = '```\nls -la\n# hide me\necho hi\n```\n'
|
|
assert modes.extract_commands(raw) == ["ls -la", "echo hi"]
|
|
|
|
|
|
def test_extract_commands_plain():
|
|
assert modes.extract_commands("whoami\necho ok") == ["whoami", "echo ok"]
|
|
assert modes.extract_commands("# comment only") == []
|
|
|
|
|
|
def test_extract_commands_dollar_prompt():
|
|
assert modes.extract_commands("$ git status") == ["git status"]
|
|
|
|
|
|
def test_minimal_uid():
|
|
assert modes.minimal_uid("abc12345", ["abc99999", "abc12346"]) == "abc12345"
|
|
assert modes.minimal_uid("aaa11111", []) == "aaa1"
|
|
|
|
|
|
def test_session_name():
|
|
assert modes.session_name(" why is the sky blue? ") == "sb: why is the sky blue?"
|
|
|
|
|
|
def test_expert_prompts():
|
|
task = modes.expert_brief_task("hello")
|
|
assert task.startswith("Stage 1 - Produce an Expert Brief")
|
|
assert "USER PROMPT:" in task and "hello" in task
|
|
assert "prompt writing agent" not in task
|
|
retry = modes.expert_retry_task("hello", "META")
|
|
assert retry.startswith("Stage 2 - Adopt the following Expert Persona")
|
|
assert "EXPERT PERSONA" in retry and "META" in retry
|
|
assert "USER PROMPT:" in retry and "hello" in retry
|
|
|
|
|
|
def _ws(ws_id, persona, state, updated):
|
|
ns = type("W", (), {})()
|
|
ns.ws_id = ws_id
|
|
ns.persona = persona
|
|
ns.state = state
|
|
ns.updated = updated
|
|
return ns
|
|
|
|
|
|
def test_select_stale_keeps_newest_n():
|
|
from shellbound.client import select_stale_workspaces
|
|
|
|
workstreams = [
|
|
_ws("aaaa", "shellbound_answer", "idle", "2026-09-05T10:00:00"),
|
|
_ws("bbbb", "shellbound_answer", "idle", "2026-09-05T11:00:00"),
|
|
_ws("cccc", "engineer", "idle", "2026-09-05T12:00:00"), # not shellbound_
|
|
_ws("dddd", "shellbound_shell", "idle", "2026-09-05T09:00:00"),
|
|
]
|
|
stale = select_stale_workspaces(workstreams, keep=2)
|
|
# newest two shellbound_* kept (bbbb, aaaa); only the oldest is stale
|
|
assert [w.ws_id for w in stale] == ["dddd"]
|
|
|
|
|
|
def test_select_stale_excludes_closed_and_exclude_id():
|
|
from shellbound.client import select_stale_workspaces
|
|
|
|
workstreams = [
|
|
_ws("aaaa", "shellbound_answer", "closed", "2026-09-05T10:00:00"),
|
|
_ws("bbbb", "shellbound_answer", "idle", "2026-09-05T11:00:00"),
|
|
_ws("cccc", "shellbound_shell", "idle", "2026-09-05T09:00:00"),
|
|
_ws("dddd", "shellbound_shell", "idle", "2026-09-05T08:00:00"),
|
|
]
|
|
# bbbb is the newest but excluded (it's the session just used); only
|
|
# cccc remains inside keep=1, so the stale set is just dddd.
|
|
stale = select_stale_workspaces(workstreams, keep=1, exclude_id="bbbb")
|
|
assert [w.ws_id for w in stale] == ["dddd"]
|
|
|
|
|
|
def test_select_stale_keep_zero():
|
|
from shellbound.client import select_stale_workspaces
|
|
|
|
workstreams = [_ws("aaaa", "shellbound_answer", "idle", "2026-09-05T10:00:00")]
|
|
assert [w.ws_id for w in select_stale_workspaces(workstreams, keep=0)] == ["aaaa"]
|
|
|
|
|
|
def test_config_prunes_node_base_template(tmp_path, monkeypatch):
|
|
from shellbound import config as c
|
|
|
|
cfg_file = tmp_path / "config.json"
|
|
cfg_file.write_text(
|
|
json.dumps({"gateway": "https://x:1", "token": "t", "node_base_template": "{gateway}/bad"})
|
|
)
|
|
monkeypatch.setattr(c, "CONFIG_FILE", cfg_file)
|
|
cfg = c.Config.load()
|
|
assert "node_base_template" not in cfg
|
|
assert cfg["gateway"] == "https://x:1"
|
|
assert cfg.keep_workspaces == 5
|
|
|
|
|
|
def test_config_keep_workspaces_environ(tmp_path, monkeypatch):
|
|
from shellbound import config as c
|
|
|
|
monkeypatch.setattr(c, "CONFIG_FILE", tmp_path / "missing.json")
|
|
monkeypatch.setenv("SHELLBOUND_KEEP_WORKSPACES", "3")
|
|
assert c.Config.load().keep_workspaces == 3
|
|
|
|
|
|
def test_config_keep_workspaces_edge_values():
|
|
from shellbound.config import Config
|
|
|
|
assert Config({"keep_workspaces": "oops"}).keep_workspaces == 5
|
|
assert Config({}).keep_workspaces == 5
|
|
assert Config({"keep_workspaces": 0}).keep_workspaces == 0
|
|
|
|
|
|
def test_normalize_session_and_close():
|
|
from shellbound.cli import _normalize_session
|
|
|
|
assert _normalize_session(["--session", "abc", "hi"]) == ["--session=abc", "hi"]
|
|
assert _normalize_session(["--session"]) == ["--session=__list__"]
|
|
assert _normalize_session(["--close", "all"]) == ["--close=all"]
|
|
assert _normalize_session(["--close", "--debug"]) == ["--close=__all__", "--debug"]
|
|
|
|
|
|
def test_renderer_flush_paragraphs():
|
|
from shellbound.render import StreamRenderer
|
|
|
|
renderer = StreamRenderer(plain=False)
|
|
renderer.add_content("First paragraph.\n")
|
|
assert not renderer.flushed # in-flight tail, not yet a block
|
|
assert renderer.tail.endswith("\n")
|
|
renderer.add_content("Second.\n\n")
|
|
assert len(renderer.flushed) == 1 and not renderer.tail
|
|
|
|
|
|
def test_renderer_flush_code_fence():
|
|
from shellbound.render import StreamRenderer
|
|
|
|
renderer = StreamRenderer(plain=False)
|
|
# blank lines inside an open fence must not flush; the closed fence
|
|
# becomes a single Markdown block on the trailing blank line.
|
|
renderer.add_content("```bash\necho a\n\n echo b \n```\n\n")
|
|
assert len(renderer.flushed) == 1 and not renderer.tail
|
|
# an unclosed fence cap is force-flushed as raw text
|
|
renderer.add_content("```python\ndef f():\n")
|
|
assert len(renderer.flushed) == 1 # still inside the fence, nothing flushed
|
|
long = "x" * 5000 + "\n"
|
|
renderer.add_content(long)
|
|
assert len(renderer.flushed) == 2 and not renderer.tail
|
|
|
|
|
|
def test_renderer_plain_content_streams():
|
|
from shellbound.render import StreamRenderer
|
|
|
|
renderer = StreamRenderer(plain=True)
|
|
renderer.add_content("just text")
|
|
assert renderer.content_parts == ["just text"]
|
|
assert renderer.finish() == "just text" |