fix(athlete): drop athlete-tag filter, harden write guardrail, guard empty echo

- get_athlete_summary no longer offers a tags filter: the endpoint's tags param
  filters ATHLETES (coach-facing), not activities, so it produced falsely-empty
  or wrongly-unfiltered summaries (confirmed against the OpenAPI description).
- update_sport_settings: an answered elicitation is now authoritative — accept
  without the confirm tick is a refusal that stops WITHOUT emitting the
  confirm=true fallback instructions (an agentic client could use them to bypass
  the refusal), and an explicit confirm param cannot override it. The elicit
  except no longer swallows failures silently (logged), and an empty-body PUT
  echo renders the merged record instead of an empty settings block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
This commit is contained in:
2026-07-20 16:27:52 -04:00
parent e5b9606bb8
commit 700cad57ef
2 changed files with 48 additions and 20 deletions
+27 -9
View File
@@ -187,19 +187,12 @@ def test_get_athlete_summary_success(monkeypatch):
assert call["url"] == "/athlete/i1/athlete-summary"
assert call["params"]["start"] == "2026-06-20"
assert call["params"]["end"] == "2026-07-20"
assert "tags" not in call["params"]
assert "Fitness (CTL): 78.5" in out
assert "Form (TSB): 7.5" in out
assert "By category:" in out
assert "Ride: 8 activities" in out
def test_get_athlete_summary_tags_split(monkeypatch):
calls = _patch_request(monkeypatch, SUMMARY)
asyncio.run(athlete.get_athlete_summary(tags="race, key-workout"))
assert calls[0]["params"]["tags"] == ["race", "key-workout"]
def test_get_athlete_summary_defaults_dates(monkeypatch):
calls = _patch_request(monkeypatch, SUMMARY)
asyncio.run(athlete.get_athlete_summary())
@@ -266,7 +259,8 @@ def test_update_sport_settings_elicit_decline(monkeypatch):
calls = _patch_seq(monkeypatch, [CURRENT_SS])
ctx = _StubCtx(action="decline")
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=300, ctx=ctx))
assert "you declined" in out
assert "did not confirm" in out
assert "confirm=true" not in out # no bypass instructions after a refusal
assert len(calls) == 1 # no PUT
@@ -274,7 +268,21 @@ def test_update_sport_settings_elicit_cancel(monkeypatch):
_patch_seq(monkeypatch, [CURRENT_SS])
ctx = _StubCtx(action="cancel")
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=300, ctx=ctx))
assert "you declined" in out
assert "did not confirm" in out
def test_update_sport_settings_accept_without_confirm_refuses_hard(monkeypatch):
# Submitting the elicitation without ticking confirm is a refusal: the tool
# must stop and must NOT emit the confirm=true bypass instructions — and an
# explicit confirm=True param must not override the answered elicitation.
calls = _patch_seq(monkeypatch, [CURRENT_SS])
ctx = _StubCtx(action="accept", confirm=False)
out = asyncio.run(
athlete.update_sport_settings(settings_id=100, ftp=300, confirm=True, ctx=ctx)
)
assert "did not confirm" in out
assert "confirm=true" not in out
assert len(calls) == 1 # no PUT
def test_update_sport_settings_elicit_unsupported_falls_back(monkeypatch):
@@ -307,3 +315,13 @@ def test_update_sport_settings_put_error(monkeypatch):
_patch_seq(monkeypatch, [CURRENT_SS, {"error": True, "message": "rejected"}])
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=300, confirm=True))
assert "Error updating sport settings: rejected" in out
def test_update_sport_settings_empty_echo_renders_merged(monkeypatch):
# An empty-body 200 parses to {}; the confirmation must render the merged
# record (with the new FTP), not format_sport_settings({}).
calls = _patch_seq(monkeypatch, [CURRENT_SS, {}])
out = asyncio.run(athlete.update_sport_settings(settings_id=100, ftp=300, confirm=True))
assert len(calls) == 2
assert "FTP: 300W" in out
assert "Settings ID: 100" in out