All files / src tool-dispatch.ts

93.22% Statements 55/59
88.23% Branches 15/17
100% Functions 7/7
93.22% Lines 55/59

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113                1x 1x 1x 1x 1x 1x                   1x 1x 15x 15x 15x   1x 1x 1x 1x   1x 1x 1x 1x   1x 1x       1x 1x   1x   2x 2x 2x   1x       1x         1x 1x 1x                                       25x 25x 1x 1x 1x 1x 1x   24x   25x 3x 3x 3x 3x 3x   21x 21x 20x 25x 1x 1x 1x 25x     1x  
/**
 * Tool dispatch registry for computation tools available to the AI.
 *
 * Maps tool names to their implementations. When Claude calls a tool,
 * the provider routes the call through dispatchTool() and returns the
 * result as a tool_result block.
 */
 
import { estimate1rm } from './tools/estimate-1rm.js';
import { calculateVolume } from './tools/calculate-volume.js';
import { estimateWorkoutTime } from './tools/estimate-workout-time.js';
import { suggestProgression } from './tools/suggest-progression.js';
import { validateSession } from './tools/validate-session.js';
import { lookupExercises, type LookupExercisesContext, type LookupExercisesInput } from './tools/lookup-exercises.js';
 
// ── Tool handler type ────────────────────────────────────────
 
// DB-backed tools (lookup_exercises) receive the optional dispatch context;
// pure tools ignore it.
type ToolHandler = (input: unknown, context?: LookupExercisesContext) => unknown | Promise<unknown>;
 
// ── Registry ─────────────────────────────────────────────────
 
const toolRegistry: Record<string, ToolHandler> = {
  estimate_1rm: (input) => {
    const { weight_kg, reps } = input as { weight_kg: number; reps: number };
    return { one_rep_max_kg: estimate1rm(weight_kg, reps) };
  },
 
  calculate_volume: (input) => {
    const { sets } = input as { sets: Parameters<typeof calculateVolume>[0] };
    return calculateVolume(sets);
  },
 
  estimate_workout_time: (input) => {
    const { prescribed_exercises } = input as { prescribed_exercises: Parameters<typeof estimateWorkoutTime>[0] };
    return { estimated_minutes: estimateWorkoutTime(prescribed_exercises) };
  },
 
  suggest_progression: (input) => {
    const { previous_sets, target_rep_range } = input as {
      previous_sets: Parameters<typeof suggestProgression>[0];
      target_rep_range: Parameters<typeof suggestProgression>[1];
    };
    return suggestProgression(previous_sets, target_rep_range);
  },
 
  validate_session: (input) => {
    // Membership check is skipped here — only the platform call site in generate-workouts/index.ts supplies validExerciseIds.
    const workout = input as Parameters<typeof validateSession>[0];
    return validateSession(workout);
  },
 
  lookup_exercises: (input, context) => {
    // lookup_exercises requires a Supabase client + athleteId in context.
    // When called without context (e.g. pure test env or mis-wired caller),
    // return a clear error so the model knows to reconfigure.
    if (!context) {
      return Promise.resolve(null).then(() => {
        throw new Error('lookup_exercises requires a dispatch context (supabase + athleteId) — missing from call site');
      });
    }
    return lookupExercises(input as LookupExercisesInput, context);
  },
};
 
// ── Dispatch function ─────────────────────────────────────────
 
export interface ToolDispatchResult {
  /** The serialisable result to return to Claude as a tool_result */
  output: unknown;
  /** True if the tool threw an error */
  isError: boolean;
}
 
/**
 * Dispatch a tool call by name.
 *
 * Accepts an optional `context` (Supabase client + athleteId) for DB-backed
 * tools like `lookup_exercises`. Pure computation tools ignore the context.
 *
 * Returns a result object with `output` and `isError`. Unknown tool names
 * produce an error result so Claude can recover gracefully.
 */
export async function dispatchTool(toolName: string, input: unknown, context?: LookupExercisesContext): Promise<ToolDispatchResult> {
  if (toolName === 'lookup_exercises' && !context) {
    return {
      output: `lookup_exercises requires a dispatch context (supabase + athleteId) — context was not provided`,
      isError: true,
    };
  }
 
  const handler = toolRegistry[toolName];
 
  if (!handler) {
    return {
      output: `Unknown tool: "${toolName}". Available tools: ${Object.keys(toolRegistry).join(', ')}`,
      isError: true,
    };
  }
 
  try {
    const output = await handler(input, context);
    return { output, isError: false };
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    return { output: `Tool "${toolName}" threw an error: ${message}`, isError: true };
  }
}
 
/** Names of all registered computation tools */
export const REGISTERED_TOOL_NAMES = Object.keys(toolRegistry);