"""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"