Skip to content

Commit 3a01612

Browse files
authored
feat(tools): add list/delete workout tools for intervals.icu (#40)
* feat(tools): add list/delete workout tools for intervals.icu Let the athlete remove scheduled workouts they no longer want. Agent calls intervals_list_events first, shows the list, and asks which to delete. intervals_delete_workout uses ?notBefore=today so the server protects history — past workouts cannot be deleted. * refactor(tools): slim intervals_list_events payload, align error shape Project events to {id, start_date_local, name, moving_time, icu_training_load} so the LLM sees identifiable workouts without workout_doc and other bloat. Rename delete error field message -> details to match the convention in intervals_create_workout. * fix(tools): enforce past-workout protection client-side The intervals.icu ?notBefore query param only caps the ?others=true cascade — it does not protect the target of a single-event DELETE. Direct API probes confirmed past-dated events get deleted regardless of the notBefore value. Switch to a client-side guard: events.get() → compare startDateLocal against today → refuse with past_workout_protected before calling delete. Also fix a runtime mismatch where the library types are snake_case but the runtime runs camelCaseKeys on every parsed response (so fetched.value.start_date_local was always undefined). Add a local IntervalsEventRuntime type reflecting the real shape. Drop the now-unnecessary intervalsAuth plumbing (no more raw fetch). Mock updates: add GET /events/:id (needed by the new guard) and remove the pretend notBefore enforcement from DELETE so the mock matches real server behavior.
1 parent 8f793ce commit 3a01612

2 files changed

Lines changed: 119 additions & 2 deletions

File tree

src/agent/tools.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,17 @@ export function createTools(memory: Memory, intervals: IntervalsClient | null) {
215215
// INTERVALS.ICU TOOLS
216216
// ============================================================================
217217

218+
// intervals-icu-api's TypeScript types declare snake_case fields, but the runtime
219+
// runs `camelCaseKeys` over every parsed response. So the types lie: at runtime we
220+
// see `startDateLocal`, not `start_date_local`. This local type reflects reality.
221+
type IntervalsEventRuntime = {
222+
id: number;
223+
startDateLocal: string;
224+
name?: string | null;
225+
movingTime?: number | null;
226+
icuTrainingLoad?: number | null;
227+
};
228+
218229
function createIntervalsTools(client: IntervalsClient) {
219230
return {
220231
intervals_fetch_athlete: tool({
@@ -303,5 +314,62 @@ function createIntervalsTools(client: IntervalsClient) {
303314
return { created: true, event: result.value };
304315
},
305316
}),
317+
318+
intervals_list_events: tool({
319+
description:
320+
"List scheduled calendar workouts on intervals.icu for a date range. " +
321+
"Use this BEFORE deleting so you can show the athlete the list (id, date, name) " +
322+
"and ask which one to delete. Filters to WORKOUT category only.",
323+
inputSchema: zodSchema(
324+
z.object({
325+
oldest: z.string().describe("Oldest date (YYYY-MM-DD)"),
326+
newest: z.string().optional().describe("Newest date (YYYY-MM-DD)"),
327+
}),
328+
),
329+
execute: async (input: { oldest: string; newest?: string }) => {
330+
const result = await client.events.list({
331+
oldest: input.oldest,
332+
newest: input.newest ?? undefined,
333+
category: ["WORKOUT"],
334+
});
335+
if (!result.ok) return { error: result.error.kind };
336+
return (result.value as unknown as IntervalsEventRuntime[]).map((e) => ({
337+
id: e.id,
338+
startDateLocal: e.startDateLocal,
339+
name: e.name,
340+
movingTime: e.movingTime,
341+
icuTrainingLoad: e.icuTrainingLoad,
342+
}));
343+
},
344+
}),
345+
346+
intervals_delete_workout: tool({
347+
description:
348+
"Delete a scheduled workout from the intervals.icu calendar by event ID. " +
349+
"ALWAYS call intervals_list_events first, show the athlete the list, and " +
350+
"confirm which workout to delete before calling this. Past workouts (before " +
351+
"today) are protected — the tool refuses without calling the server.",
352+
inputSchema: zodSchema(
353+
z.object({
354+
eventId: z.number().int().describe("Event ID from intervals_list_events"),
355+
}),
356+
),
357+
execute: async (input: { eventId: number }) => {
358+
const fetched = await client.events.get(input.eventId);
359+
if (!fetched.ok) return { error: fetched.error.kind };
360+
const event = fetched.value as unknown as IntervalsEventRuntime;
361+
const today = new Date().toISOString().split("T")[0];
362+
const eventDate = event.startDateLocal.slice(0, 10);
363+
if (eventDate < today) {
364+
return {
365+
error: "past_workout_protected",
366+
details: `Cannot delete workout dated ${eventDate} — it's before today (${today}).`,
367+
};
368+
}
369+
const result = await client.events.delete(input.eventId);
370+
if (!result.ok) return { error: result.error.kind };
371+
return { deleted: true };
372+
},
373+
}),
306374
};
307375
}

tests/helpers/mock-intervals.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@
66
* a mutable array of every workout POSTed via the events endpoint.
77
*
88
* Usage:
9-
* const { server, createdWorkouts } = createMockIntervalsServer();
9+
* const { server, createdWorkouts, deletedEventIds } = createMockIntervalsServer();
1010
* server.listen({ onUnhandledRequest: "bypass" });
1111
* // ... run agent ...
1212
* server.close();
1313
* console.log(createdWorkouts); // inspect created workouts
14+
* console.log(deletedEventIds); // inspect deleted event IDs
1415
*/
1516

1617
import { http, HttpResponse } from "msw";
@@ -294,6 +295,7 @@ export function createMockIntervalsServer(options: MockIntervalsOptions = {}) {
294295
const activities = defaultActivities(options.activities);
295296
const wellness = defaultWellness(options.wellness);
296297
const createdWorkouts: CreatedWorkout[] = [];
298+
const deletedEventIds: number[] = [];
297299
let nextEventId = 5000;
298300

299301
const handlers = [
@@ -352,9 +354,56 @@ export function createMockIntervalsServer(options: MockIntervalsOptions = {}) {
352354
createdWorkouts.push(workout);
353355
return HttpResponse.json({ id: eventId, ...body }, { status: 200 });
354356
}),
357+
358+
// GET /api/v1/athlete/:id/events — list scheduled events
359+
http.get("https://intervals.icu/api/v1/athlete/:id/events", ({ request }) => {
360+
const url = new URL(request.url);
361+
const oldest = url.searchParams.get("oldest");
362+
const newest = url.searchParams.get("newest");
363+
364+
let filtered = createdWorkouts;
365+
if (oldest) {
366+
filtered = filtered.filter((w) => w.start_date_local >= oldest);
367+
}
368+
if (newest) {
369+
filtered = filtered.filter((w) => w.start_date_local <= newest + "T23:59:59");
370+
}
371+
return HttpResponse.json(filtered);
372+
}),
373+
374+
// GET /api/v1/athlete/:id/events/:eventId — fetch single event (used by the
375+
// delete tool to read the authoritative date before deciding whether to delete)
376+
http.get(
377+
"https://intervals.icu/api/v1/athlete/:id/events/:eventId",
378+
({ params }) => {
379+
const eventId = Number(params.eventId);
380+
const workout = createdWorkouts.find((w) => w.id === eventId);
381+
if (!workout) {
382+
return HttpResponse.json({ error: "not_found" }, { status: 404 });
383+
}
384+
return HttpResponse.json(workout);
385+
},
386+
),
387+
388+
// DELETE /api/v1/athlete/:id/events/:eventId — delete scheduled event
389+
// (no notBefore enforcement — the real API's notBefore only caps the `others`
390+
// cascade, not the target event, so protection lives client-side in the tool)
391+
http.delete(
392+
"https://intervals.icu/api/v1/athlete/:id/events/:eventId",
393+
({ params }) => {
394+
const eventId = Number(params.eventId);
395+
const idx = createdWorkouts.findIndex((w) => w.id === eventId);
396+
if (idx === -1) {
397+
return HttpResponse.json({ error: "not_found" }, { status: 404 });
398+
}
399+
createdWorkouts.splice(idx, 1);
400+
deletedEventIds.push(eventId);
401+
return new HttpResponse(null, { status: 200 });
402+
},
403+
),
355404
];
356405

357406
const server = setupServer(...handlers);
358407

359-
return { server, createdWorkouts };
408+
return { server, createdWorkouts, deletedEventIds };
360409
}

0 commit comments

Comments
 (0)