Skip to content

Commit 9e3cf2a

Browse files
committed
feat(plugin): add agent handoff animation on agent switch (#1038)
Track previous agent in build_status_message() and generate a handoff message (e.g. '★‿★ Frontend → ●‿● Backend 교대!') when the active agent changes. Retrievable via new get_handoff_message() function.
1 parent c1fa1c2 commit 9e3cf2a

2 files changed

Lines changed: 137 additions & 2 deletions

File tree

packages/claude-code-plugin/hooks/lib/agent_status.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@
2828
# Cache: agent_name -> visual dict
2929
_visual_cache: dict = {}
3030

31+
# Handoff tracking
32+
_previous_agent: Optional[str] = None
33+
_handoff_message: Optional[str] = None
34+
3135

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

96100

101+
def _build_face(visual: Optional[dict]) -> str:
102+
"""Build a face string from visual data, or robot emoji fallback."""
103+
if not visual:
104+
return "\U0001f916" # 🤖
105+
eye = visual.get("eye", "\u25cf") # ● default
106+
return f"{eye}\u203f{eye}" # eye‿eye
107+
108+
97109
def build_status_message(project_root: Optional[str] = None) -> Optional[str]:
98110
"""Build a one-line agent status message for spinner display.
99111
112+
Also tracks agent changes and generates a handoff message
113+
retrievable via get_handoff_message().
114+
100115
Returns:
101116
Formatted status like '🟡 ★‿★ Frontend Developer' when agent active,
102117
default 'CodingBuddy working...' when agent name set but visual not found,
103118
or None when no agent is active.
104119
"""
120+
global _previous_agent, _handoff_message
121+
_handoff_message = None # Reset each call
122+
105123
agent_name = os.environ.get("CODINGBUDDY_ACTIVE_AGENT", "")
106124
if not agent_name:
125+
_previous_agent = None
107126
return None
108127

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

115134
visual = _load_agent_visual(agent_name, project_root)
116135

136+
# Handoff detection
137+
if _previous_agent is not None and _previous_agent != agent_name:
138+
prev_visual = _visual_cache.get(_previous_agent)
139+
prev_face = _build_face(prev_visual)
140+
curr_face = _build_face(visual)
141+
_handoff_message = (
142+
f"{prev_face} {_previous_agent} \u2192 {curr_face} {agent_name} \uad50\ub300!"
143+
)
144+
145+
_previous_agent = agent_name
146+
117147
if not visual:
118148
return f"\U0001f916 {agent_name}" # 🤖 fallback
119149

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

127157

158+
def get_handoff_message() -> Optional[str]:
159+
"""Return the handoff message from the last build_status_message call.
160+
161+
Returns:
162+
A string like '★‿★ Frontend Developer → ●‿● Backend Developer 교대!'
163+
when an agent switch was detected, or None otherwise.
164+
"""
165+
return _handoff_message
166+
167+
128168
def clear_cache() -> None:
129169
"""Clear all caches. Useful for testing."""
130-
global _agents_dir_cache, _agents_dir_cache_root
170+
global _agents_dir_cache, _agents_dir_cache_root, _previous_agent, _handoff_message
131171
_agents_dir_cache = None
132172
_agents_dir_cache_root = None
133173
_visual_cache.clear()
174+
_previous_agent = None
175+
_handoff_message = None

packages/claude-code-plugin/tests/test_agent_status.py

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
# Add hooks/lib to path
99
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "hooks", "lib"))
1010

11-
from agent_status import build_status_message, clear_cache, _COLOR_EMOJI_MAP
11+
from agent_status import build_status_message, clear_cache, get_handoff_message, _COLOR_EMOJI_MAP
1212

1313

1414
@pytest.fixture(autouse=True)
@@ -178,3 +178,96 @@ def test_clear_cache_resets(self, monkeypatch, project_with_agents):
178178
clear_cache()
179179
r2 = build_status_message(project_root=project_with_agents)
180180
assert r1 == r2 # Same result, but freshly loaded
181+
182+
183+
class TestHandoffMessage:
184+
"""Tests for agent handoff detection and message generation."""
185+
186+
def test_no_handoff_on_first_call(self, monkeypatch, project_with_agents):
187+
"""First call should not produce a handoff message."""
188+
monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "frontend-developer")
189+
build_status_message(project_root=project_with_agents)
190+
assert get_handoff_message() is None
191+
192+
def test_no_handoff_when_same_agent(self, monkeypatch, project_with_agents):
193+
"""Repeated calls with the same agent should not produce a handoff."""
194+
monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "frontend-developer")
195+
build_status_message(project_root=project_with_agents)
196+
build_status_message(project_root=project_with_agents)
197+
assert get_handoff_message() is None
198+
199+
def test_handoff_when_agent_changes(self, monkeypatch, project_with_agents):
200+
"""Switching agents should produce a handoff message with both faces."""
201+
monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "frontend-developer")
202+
build_status_message(project_root=project_with_agents)
203+
204+
monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "Backend Developer")
205+
build_status_message(project_root=project_with_agents)
206+
207+
handoff = get_handoff_message()
208+
assert handoff is not None
209+
# Previous agent face: ★‿★ (frontend-developer)
210+
assert "\u2605\u203f\u2605" in handoff
211+
# New agent face: ●‿● (Backend Developer)
212+
assert "\u25cf\u203f\u25cf" in handoff
213+
# Arrow separator
214+
assert "\u2192" in handoff # →
215+
# 교대 suffix
216+
assert "\uad50\ub300!" in handoff # 교대!
217+
218+
def test_handoff_includes_agent_names(self, monkeypatch, project_with_agents):
219+
"""Handoff message should include both agent names."""
220+
monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "frontend-developer")
221+
build_status_message(project_root=project_with_agents)
222+
223+
monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "Backend Developer")
224+
build_status_message(project_root=project_with_agents)
225+
226+
handoff = get_handoff_message()
227+
assert "frontend-developer" in handoff
228+
assert "Backend Developer" in handoff
229+
230+
def test_handoff_with_fallback_face(self, monkeypatch, project_with_agents):
231+
"""Agent without visual data should use robot emoji in handoff."""
232+
monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "frontend-developer")
233+
build_status_message(project_root=project_with_agents)
234+
235+
monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "plain-agent")
236+
build_status_message(project_root=project_with_agents)
237+
238+
handoff = get_handoff_message()
239+
assert handoff is not None
240+
assert "\U0001f916" in handoff # 🤖 fallback for plain-agent
241+
242+
def test_handoff_resets_after_read(self, monkeypatch, project_with_agents):
243+
"""Handoff message should reset on next build_status_message call."""
244+
monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "frontend-developer")
245+
build_status_message(project_root=project_with_agents)
246+
247+
monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "Backend Developer")
248+
build_status_message(project_root=project_with_agents)
249+
assert get_handoff_message() is not None
250+
251+
# Same agent again — no new handoff
252+
build_status_message(project_root=project_with_agents)
253+
assert get_handoff_message() is None
254+
255+
def test_handoff_cleared_by_clear_cache(self, monkeypatch, project_with_agents):
256+
"""clear_cache should reset handoff state."""
257+
monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "frontend-developer")
258+
build_status_message(project_root=project_with_agents)
259+
260+
monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "Backend Developer")
261+
build_status_message(project_root=project_with_agents)
262+
263+
clear_cache()
264+
assert get_handoff_message() is None
265+
266+
def test_no_handoff_when_agent_unset(self, monkeypatch, project_with_agents):
267+
"""Setting agent then unsetting should not produce handoff."""
268+
monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "frontend-developer")
269+
build_status_message(project_root=project_with_agents)
270+
271+
monkeypatch.delenv("CODINGBUDDY_ACTIVE_AGENT", raising=False)
272+
build_status_message(project_root=project_with_agents)
273+
assert get_handoff_message() is None

0 commit comments

Comments
 (0)