feat(spotify): consolidate tools (9→7), add spotify skill, surface in hermes setup (#15154)

Three quality improvements on top of #15121 / #15130 / #15135:

1. Tool consolidation (9 → 7)
   - spotify_saved_tracks + spotify_saved_albums → spotify_library with
     kind='tracks'|'albums'. Handler code was ~90 percent identical
     across the two old tools; the merge is a behavioral no-op.
   - spotify_activity dropped. Its 'now_playing' action was a duplicate
     of spotify_playback.get_currently_playing (both return identical
     204/empty payloads). Its 'recently_played' action moves onto
     spotify_playback as a new action — history belongs adjacent to
     live state.
   - Net: each API call ships 2 fewer tool schemas when the Spotify
     toolset is enabled, and the action surface is more discoverable
     (everything playback-related is on one tool).

2. Spotify skill (skills/media/spotify/SKILL.md)
   Teaches the agent canonical usage patterns so common requests don't
   balloon into 4+ tool calls:
   - 'play X' = one search, then play by URI (not search + scan +
     describe + play)
   - 'what's playing' = single get_currently_playing (no preflight
     get_state chain)
   - Don't retry on '403 Premium required' or '403 No active device' —
     both require user action
   - URI/URL/bare-ID format normalization
   - Full failure-mode reference for 204/401/403/429

3. Surfaced in 'hermes setup' tool status
   Adds 'Spotify (PKCE OAuth)' to the tool status list when
   auth.json has a Spotify access/refresh token. Matches the
   homeassistant pattern but reads from auth.json (OAuth-based) rather
   than env vars.

Docs updated to reflect the new 7-tool surface, and mention the
companion skill in the 'Using it' section.

Tests: 54 passing (client 22, auth 15, tools_config 35 — 18 = 54 after
renaming/replacing the spotify_activity tests with library +
recently_played coverage). Docusaurus build clean.
This commit is contained in:
Teknium 2026-04-24 06:14:51 -07:00 committed by GitHub
parent 9d1b277e1d
commit e5d41f05d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 254 additions and 127 deletions

View file

@ -148,7 +148,7 @@ def test_get_currently_playing_returns_explanatory_empty_payload(monkeypatch: py
}
def test_spotify_activity_now_playing_returns_explanatory_empty_result(monkeypatch: pytest.MonkeyPatch) -> None:
def test_spotify_playback_get_currently_playing_returns_explanatory_empty_result(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
spotify_tool,
"_spotify_client",
@ -159,11 +159,11 @@ def test_spotify_activity_now_playing_returns_explanatory_empty_result(monkeypat
}),
)
payload = json.loads(spotify_tool._handle_spotify_activity({"action": "now_playing"}))
payload = json.loads(spotify_tool._handle_spotify_playback({"action": "get_currently_playing"}))
assert payload == {
"success": True,
"action": "now_playing",
"action": "get_currently_playing",
"is_playing": False,
"status_code": 204,
"message": "Spotify is not currently playing anything. Start playback in Spotify and try again.",
@ -242,3 +242,58 @@ def test_library_remove_uses_generic_library_endpoint(
{"uris": ",".join(expected_uris)},
)
]
def test_spotify_library_tracks_list_routes_to_saved_tracks(monkeypatch: pytest.MonkeyPatch) -> None:
seen: list[str] = []
class _LibStub:
def get_saved_tracks(self, **kw):
seen.append("tracks")
return {"items": [], "total": 0}
def get_saved_albums(self, **kw):
seen.append("albums")
return {"items": [], "total": 0}
monkeypatch.setattr(spotify_tool, "_spotify_client", lambda: _LibStub())
json.loads(spotify_tool._handle_spotify_library({"kind": "tracks", "action": "list"}))
assert seen == ["tracks"]
def test_spotify_library_albums_list_routes_to_saved_albums(monkeypatch: pytest.MonkeyPatch) -> None:
seen: list[str] = []
class _LibStub:
def get_saved_tracks(self, **kw):
seen.append("tracks")
return {"items": [], "total": 0}
def get_saved_albums(self, **kw):
seen.append("albums")
return {"items": [], "total": 0}
monkeypatch.setattr(spotify_tool, "_spotify_client", lambda: _LibStub())
json.loads(spotify_tool._handle_spotify_library({"kind": "albums", "action": "list"}))
assert seen == ["albums"]
def test_spotify_library_rejects_missing_kind() -> None:
payload = json.loads(spotify_tool._handle_spotify_library({"action": "list"}))
assert "kind" in (payload.get("error") or "").lower()
def test_spotify_playback_recently_played_action(monkeypatch: pytest.MonkeyPatch) -> None:
"""recently_played is now an action on spotify_playback (folded from spotify_activity)."""
seen: list[dict] = []
class _RecentStub:
def get_recently_played(self, **kw):
seen.append(kw)
return {"items": [{"track": {"name": "x"}}]}
monkeypatch.setattr(spotify_tool, "_spotify_client", lambda: _RecentStub())
payload = json.loads(spotify_tool._handle_spotify_playback({"action": "recently_played", "limit": 5}))
assert seen and seen[0]["limit"] == 5
assert isinstance(payload, dict)