Repository navigation
Add duration_open: manual/lap-press steps for workouts - #59
Conversation
targetType=1 was assumed broken -- a prior comment on _target_fields() said it "silently produces a zero-duration, zero-distance step with no error" and left it unused. That zero is actually the wire encoding for an open/manual step: no clock or distance cap, the step only ends when the athlete presses lap on the watch. Confirmed by building a manual-duration step in the Coros app itself (a workout named "flexible_lap_press") and reading back its raw exercises via /training/program/query -- open steps there carry targetType=1, targetValue=0, same convention as the existing duration_minutes/duration_meters encodings. Field-tested on real hardware: the watch shows no countdown and waits for a lap press before advancing to the next step. - coros_api.py: _target_fields() gains a third mutually-exclusive duration key, duration_open, alongside duration_minutes and duration_meters. Updated the stale comment and validation error messages accordingly. - server.py: save_workout_template docstring documents duration_open (schedule_workout shares the same step-shape docs by reference). - CLAUDE.md: region can mismatch geography and cause 1019s even after a login that returns a valid-looking token -- confirmed live, an account based in Germany needed `us` region, not `eu`. - tests: renamed the two validation tests to match their new error messages (three duration keys now, not two), and added coverage for duration_open alone, inside a repeat group, and its (lack of) contribution to estimated_time. Useful for warm-up/cool-down/inter-block rest whose real length varies in practice (group pace, a break, starting early) and shouldn't be forced onto a clock. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ReviewLokal auf Die Kernidee ist gut belegt (App-Capture + Read-Back über 1. Read-Path kennt
|
cygnusb
left a comment
There was a problem hiding this comment.
Verified locally on ed70b40: 228 tests pass, ruff check clean — the test plan checks out.
The core finding is well evidenced (app capture + read-back via /training/program/query + hardware test), the feature is backward compatible, and generalizing the mutual-exclusion check to three keys is done cleanly. Five points before merge — the first three are substantive.
1. The read path doesn't know targetType=1 (concrete bug)
_parse_workout() (coros_mcp/coros_api.py:729) only branches on targetType == 5; everything else falls into the else arm as duration_seconds = targetValue. Reproduced:
_parse_workout({... 'exercises': [{'targetType': 1, 'targetValue': 0, ...}]})
# → {'name': 'Warm-up', 'intensity_low': 120, ..., 'duration_seconds': 0}So an open step written by save_workout_template reads back through list_workout_templates as a zero-second time step, indistinguishable from a genuinely empty one. An agent that reads a template and writes it back silently loses the open semantics (or "fixes" the apparent 0-minute step). The write path now speaks an encoding the read path can't translate back.
Fix, mirroring the distance arm:
if ex.get("targetType") == 1:
parsed["duration_open"] = True
elif ex.get("targetType") == 5:
...2. Repeat groups with open sub-steps are unverified on the wire
For a group whose sub-steps are all open, iteration_seconds = 0, so the header goes out as targetType=2, targetValue=0. That is exactly the shape the comment at coros_api.py:986 ff. says the server normalizes into a distance header (targetType=5, meters ×100) for pure-distance groups. What it does with a group whose iteration has neither time nor distance is unknown. Reproduced:
all-open repeat group → header (2, 0, isGroup) + (1, 0)
distance+open repeat group → header (2, 0, isGroup) + (5, 40000) + (1, 0)
The hardware test, per the description, covers a plain open step; test_open_step_in_repeat_group is a payload-shape unit test only. Since Coros returns 0000 for wrong encodings and silently yields zeros, I'd round-trip both shapes above against the live API (schedule → fetch_schedule_raw → remove_scheduled_workout, far-future HAPPEN_DAY) before the docs recommend open steps inside repeat groups — which the new test docstring already does with its "jog back down the hill" example.
3. _summarize_steps / the mixed-duration warning weren't updated
server.py:175–225 still only knows duration_minutes and duration_meters. Consequence (reproduced):
[open warm-up, 10min tempo] → total_minutes=10.0, distance=0.0, mixed_warning=False
[open only] → total_minutes=0.0, distance=0.0, mixed_warning=False
save_workout_template returns total_minutes: 10.0 with no caveat at all, so the agent reports a "10-minute workout" to the user. The _MIXED_DURATION_WARNING machinery exists for exactly this class of understatement (docstring: "each contributes 0 to the total it doesn't measure rather than a guess … see the mixed-duration warning"), but doesn't fire here. Either include open steps in _has_mixed_durations(), or add a separate note ("contains N steps with no fixed length").
4. duration_open: False — presence vs. truthiness are inconsistent
The exclusivity check uses k in s, the branch uses s.get("duration_open"). Result:
{"duration_minutes": 5, "duration_open": False}
→ ValueError: must set exactly one of ... got ['duration_minutes', 'duration_open']
{"duration_open": False}
→ ValueError: needs duration_minutes, duration_meters, or duration_open # names a key that is already there
An LLM client that explicitly sets duration_open: false — a natural thing to do with a boolean field — gets a hard error for a well-formed time step. Either only collect truthy keys into duration_keys, or reject a falsy duration_open with its own message ("duration_open must be True; omit the key otherwise").
5. Docs left incomplete
Two places still say "two keys":
coros_mcp/coros_api.py:1146—save_workout_templatedocstring (API level): "(exactly one of the two)"README.md:348— "Exactly one of the two keys per step." That's the user-facing doc, andduration_opendoesn't appear there at all.
Minor
- The CLAUDE.md region paragraph: factually plausible and useful, but off-topic for this PR and rather long (four sentences) for a file loaded into every context — one or two would do. Where it would really help is at the point the
1019surfaces: in the auth path's error message. - Tests: good coverage of the three relevant axes (standalone, inside a group,
estimated_time), and the docstrings record the evidence — matches the style of the repo.
Summary: I'd treat point 1 (read path) and point 3 (wrong numbers reported to the user) as merge blockers. Point 2 is a verification question: either check it live, or hold off on advertising the repeat-group case in the docs.
Review feedback from @cygnusb on the duration_open PR. All five points, plus the doc spots the review didn't name. - coros_api.py `_parse_workout`: learn targetType=1. An open step written by save_workout_template came back through list_workout_templates as `duration_seconds: 0` -- indistinguishable from a genuinely empty step, so a read-modify-write round trip silently dropped the open semantics. Now parses to `duration_open: True` (no duration_seconds), matching how the distance branch already works. - server.py: open steps count toward neither total_minutes nor distance_meters_total, so `[open warm-up, 10min tempo]` used to report a bare `total_minutes: 10.0` with no caveat and an agent would relay "10-minute workout". Adds `_OPEN_DURATION_WARNING` + `_count_open_steps` (counting each repetition inside a repeat group) and routes both tools through `_attach_duration_warnings`, which concatenates the mixed-unit and open-step warnings. `_attach_mixed_duration_warning` is unchanged and still covered by its own tests. - coros_api.py `_target_fields`: presence vs truthiness. The exclusivity check used `k in s` while the branch used `.get()`, so an explicit `duration_open: False` alongside `duration_minutes` -- a natural thing for an LLM client to emit for a boolean -- was a hard error on a well-formed timed step. Falsy `duration_open` is now ignored, and a step whose only duration key is a falsy `duration_open` gets an error that says so instead of claiming the key is missing. - Repeat groups: the all-open (and open+distance) group header is the one shape still unverified on the wire -- it sends targetType=2/targetValue=0 with nothing for the server to normalize the header to, unlike the pure-distance case verified live on 2026-08-12. Documented as such in the builder; a group mixing open with timed sub-steps is unaffected. Not advertised in the README until it is checked live. - Docs: the API-level `save_workout_template` docstring and README both still said "exactly one of the two"; README had no mention of duration_open at all. Also updated the module-level targetType legend, which only listed 2 and 5, and both tools' Returns sections. - CLAUDE.md: trimmed the region paragraph to two sentences (it loads into every context) and moved the actionable part to where it fires -- a 1019 from `_check_response` now carries the "try the other region" hint. - Tests: +11. Open-step read-back and full write->read round trip, falsy `duration_open` ignored and its pointed error message, open steps in the server summaries, per-repetition open counting, and warning concatenation with the mixed-unit warning. 239 passed, ruff clean, mypy clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Pushed
On 2 — agreed, not verified live. The builder now documents that the unverified shape is specifically a group whose iteration has neither time nor distance (all-open, or open+distance); a group mixing open with a timed sub-step still carries the timed seconds in its header, which is what Unrelated pre-existing find while in |
The doc sweep in f927b6a updated the write-side tools (save_workout_template, schedule_workout) but not list_workout_templates, which is the tool that actually emits the new key: its Returns section still promised "either duration_seconds ... or distance_meters", so an agent reading the docstring would not expect duration_open on a step read back from the library. README's list_workout_templates entry listed only duration_seconds (it never mentioned distance_meters either); both are named now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AS7vhBeyEgejDrzJTbX2Ko
cygnusb
left a comment
There was a problem hiding this comment.
Verified on f927b6a: 239 tests pass, ruff check clean, mypy clean (13 files).
I re-ran each of the five points against the new head rather than reading the diff alone:
| # | before | now |
|---|---|---|
| 1 | targetType=1 → duration_seconds: 0 |
→ {'duration_open': True}, no duration_seconds ✔ |
| 3 | [open, 10min] → bare total_minutes=10.0 |
→ warning "has 1 open step(s) … The real workout is longer" ✔ |
| 4 | {"duration_minutes": 5, "duration_open": False} → ValueError |
→ accepted, targetType=2 ✔ |
| 4b | falsy-only duration_open → misleading message |
→ "duration_open is present but falsy — it must be True" ✔ |
| 5 | docstring + README said "two" | both updated, plus the module-level targetType legend ✔ |
Two of the fixes are better than what I suggested:
- Point 3: a separate
_OPEN_DURATION_WARNING+_attach_duration_warningscollector, instead of widening_has_mixed_durations. The existing path and its tests stay untouched and the two caveats concatenate cleanly — right call, they have different causes. - Point 4: the truthiness exception is scoped to
duration_openonly (k in s and (k != "duration_open" or s[k])), soduration_meters: 0still correctly fails with "must be positive".
The region hint moved to _check_response is safe with the retry path: _run_with_auth matches _AUTH_RESULT_CODES against the result code, not the message text.
Point 2 — agreed as a documented deferral. The comment at coros_api.py:1014 ff. draws the line precisely (an iteration with neither time nor distance is the unverified shape; open + timed is not), and it isn't advertised in the README. Good until the live round-trip happens.
I pushed one follow-up commit to this branch (263814e): the doc sweep covered the write-side tools but not list_workout_templates, which is the tool that actually emits the new key — its Returns section still promised "either duration_seconds … or distance_meters". README's entry for it named only duration_seconds (it never mentioned distance_meters either); both are listed now. Tests/ruff/mypy still clean.
Two notes, neither needing action:
_count_open_steps()counts executions (16 for a 16× group) whilesteps_countcounts structure (2). Both land in the same response dict, so an agent readssteps_count: 2next to "has 16 open step(s)". The choice is deliberate and documented, and it's the more useful number for the warning — only the wording ("16 open step executions") would remove the friction._REGION_HINTis appended to every1019, including the expired-token ones_run_with_authretries; if that retry fails for another reason the region advice reads as a red herring. The "if this persists right after a successful login" hedge covers it.
Nice work on the evidence trail — the test docstrings recording how each encoding was confirmed are what makes this reviewable at all. Answering #60's three questions separately.
Summary
targetType=1was assumed broken — a comment on_target_fields()said it "silently produces a zero-duration, zero-distance step with no error" and left it unused. That zero is actually the wire encoding for an open/manual step: no clock or distance cap, the step only ends when the athlete presses lap on the watch.Confirmed by building a manual-duration step in the Coros app itself (a workout named
flexible_lap_press) and reading back its raw exercises via/training/program/query— open steps there carrytargetType=1, targetValue=0, the same convention as the existingduration_minutes/duration_metersencodings. Field-tested on real hardware: the watch shows no countdown and waits for a lap press before advancing to the next step.coros_api.py:_target_fields()gains a third mutually-exclusive duration key,duration_open, alongsideduration_minutesandduration_meters. Updated the stale comment and validation error messages accordingly.server.py:save_workout_templatedocstring documentsduration_open(schedule_workoutshares the same step-shape docs by reference).CLAUDE.md: one small factual addition — region can mismatch geography and cause1019s even after a login that returns a valid-looking token. Confirmed live: an account based in Germany neededusregion, noteu.duration_openalone, inside a repeat group, and its (lack of) contribution toestimated_time.Useful for warm-up/cool-down/inter-block rest whose real length varies in practice (group pace, a break, starting early) and shouldn't be forced onto a clock.
Test plan
pytest— 228 passed, 0 failedruff check— clean🤖 Generated with Claude Code