Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion packages/claude-code-plugin/hooks/lib/agent_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@
# Cache: agent_name -> visual dict
_visual_cache: dict = {}

# Handoff tracking
_previous_agent: Optional[str] = None
_handoff_message: Optional[str] = None


def _find_agents_dir(project_root: str) -> Optional[str]:
"""Find the .ai-rules/agents directory from the project root."""
Expand Down Expand Up @@ -94,16 +98,31 @@ def _load_agent_visual(agent_name: str, project_root: str) -> Optional[dict]:
return None


def _build_face(visual: Optional[dict]) -> str:
"""Build a face string from visual data, or robot emoji fallback."""
if not visual:
return "\U0001f916" # 🤖
eye = visual.get("eye", "\u25cf") # ● default
return f"{eye}\u203f{eye}" # eye‿eye


def build_status_message(project_root: Optional[str] = None) -> Optional[str]:
"""Build a one-line agent status message for spinner display.

Also tracks agent changes and generates a handoff message
retrievable via get_handoff_message().

Returns:
Formatted status like '🟡 ★‿★ Frontend Developer' when agent active,
default 'CodingBuddy working...' when agent name set but visual not found,
or None when no agent is active.
"""
global _previous_agent, _handoff_message
_handoff_message = None # Reset each call

agent_name = os.environ.get("CODINGBUDDY_ACTIVE_AGENT", "")
if not agent_name:
_previous_agent = None
return None

if project_root is None:
Expand All @@ -114,6 +133,17 @@ def build_status_message(project_root: Optional[str] = None) -> Optional[str]:

visual = _load_agent_visual(agent_name, project_root)

# Handoff detection
if _previous_agent is not None and _previous_agent != agent_name:
prev_visual = _visual_cache.get(_previous_agent)
prev_face = _build_face(prev_visual)
curr_face = _build_face(visual)
_handoff_message = (
f"{prev_face} {_previous_agent} \u2192 {curr_face} {agent_name} \uad50\ub300!"
)

_previous_agent = agent_name

if not visual:
return f"\U0001f916 {agent_name}" # 🤖 fallback

Expand All @@ -125,9 +155,21 @@ def build_status_message(project_root: Optional[str] = None) -> Optional[str]:
return f"{emoji} {face} {agent_name}"


def get_handoff_message() -> Optional[str]:
"""Return the handoff message from the last build_status_message call.

Returns:
A string like '★‿★ Frontend Developer → ●‿● Backend Developer 교대!'
when an agent switch was detected, or None otherwise.
"""
return _handoff_message


def clear_cache() -> None:
"""Clear all caches. Useful for testing."""
global _agents_dir_cache, _agents_dir_cache_root
global _agents_dir_cache, _agents_dir_cache_root, _previous_agent, _handoff_message
_agents_dir_cache = None
_agents_dir_cache_root = None
_visual_cache.clear()
_previous_agent = None
_handoff_message = None
95 changes: 94 additions & 1 deletion packages/claude-code-plugin/tests/test_agent_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
# Add hooks/lib to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "hooks", "lib"))

from agent_status import build_status_message, clear_cache, _COLOR_EMOJI_MAP
from agent_status import build_status_message, clear_cache, get_handoff_message, _COLOR_EMOJI_MAP


@pytest.fixture(autouse=True)
Expand Down Expand Up @@ -178,3 +178,96 @@ def test_clear_cache_resets(self, monkeypatch, project_with_agents):
clear_cache()
r2 = build_status_message(project_root=project_with_agents)
assert r1 == r2 # Same result, but freshly loaded


class TestHandoffMessage:
"""Tests for agent handoff detection and message generation."""

def test_no_handoff_on_first_call(self, monkeypatch, project_with_agents):
"""First call should not produce a handoff message."""
monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "frontend-developer")
build_status_message(project_root=project_with_agents)
assert get_handoff_message() is None

def test_no_handoff_when_same_agent(self, monkeypatch, project_with_agents):
"""Repeated calls with the same agent should not produce a handoff."""
monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "frontend-developer")
build_status_message(project_root=project_with_agents)
build_status_message(project_root=project_with_agents)
assert get_handoff_message() is None

def test_handoff_when_agent_changes(self, monkeypatch, project_with_agents):
"""Switching agents should produce a handoff message with both faces."""
monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "frontend-developer")
build_status_message(project_root=project_with_agents)

monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "Backend Developer")
build_status_message(project_root=project_with_agents)

handoff = get_handoff_message()
assert handoff is not None
# Previous agent face: ★‿★ (frontend-developer)
assert "\u2605\u203f\u2605" in handoff
# New agent face: ●‿● (Backend Developer)
assert "\u25cf\u203f\u25cf" in handoff
# Arrow separator
assert "\u2192" in handoff # →
# 교대 suffix
assert "\uad50\ub300!" in handoff # 교대!

def test_handoff_includes_agent_names(self, monkeypatch, project_with_agents):
"""Handoff message should include both agent names."""
monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "frontend-developer")
build_status_message(project_root=project_with_agents)

monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "Backend Developer")
build_status_message(project_root=project_with_agents)

handoff = get_handoff_message()
assert "frontend-developer" in handoff
assert "Backend Developer" in handoff

def test_handoff_with_fallback_face(self, monkeypatch, project_with_agents):
"""Agent without visual data should use robot emoji in handoff."""
monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "frontend-developer")
build_status_message(project_root=project_with_agents)

monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "plain-agent")
build_status_message(project_root=project_with_agents)

handoff = get_handoff_message()
assert handoff is not None
assert "\U0001f916" in handoff # 🤖 fallback for plain-agent

def test_handoff_resets_after_read(self, monkeypatch, project_with_agents):
"""Handoff message should reset on next build_status_message call."""
monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "frontend-developer")
build_status_message(project_root=project_with_agents)

monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "Backend Developer")
build_status_message(project_root=project_with_agents)
assert get_handoff_message() is not None

# Same agent again — no new handoff
build_status_message(project_root=project_with_agents)
assert get_handoff_message() is None

def test_handoff_cleared_by_clear_cache(self, monkeypatch, project_with_agents):
"""clear_cache should reset handoff state."""
monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "frontend-developer")
build_status_message(project_root=project_with_agents)

monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "Backend Developer")
build_status_message(project_root=project_with_agents)

clear_cache()
assert get_handoff_message() is None

def test_no_handoff_when_agent_unset(self, monkeypatch, project_with_agents):
"""Setting agent then unsetting should not produce handoff."""
monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "frontend-developer")
build_status_message(project_root=project_with_agents)

monkeypatch.delenv("CODINGBUDDY_ACTIVE_AGENT", raising=False)
build_status_message(project_root=project_with_agents)
assert get_handoff_message() is None
Loading