From 17b74b4f1fe9f75649b5b85897a157ee5a6de20e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Jun 2025 04:05:13 +0100 Subject: [PATCH] accept path as silent fallback parameter --- src/tools/analyze.ts | 24 +++++++--- tests/unit/tools/analyze.test.ts | 79 ++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/src/tools/analyze.ts b/src/tools/analyze.ts index f45c374..19609ef 100644 --- a/src/tools/analyze.ts +++ b/src/tools/analyze.ts @@ -11,6 +11,7 @@ import { export const analyzeToolSchema = z.object({ image_path: z .string() + .optional() .describe( "Required. Absolute path to image file (.png, .jpg, .webp) to be analyzed.", ), @@ -36,7 +37,15 @@ export const analyzeToolSchema = z.object({ .describe( "Optional. Explicit provider/model. Validated against server's PEEKABOO_AI_PROVIDERS.", ), -}); + // Silent fallback parameter (not advertised in schema) + path: z.string().optional(), +}).refine( + (data) => data.image_path || data.path, + { + message: "image_path is required", + path: ["image_path"], + } +); export type AnalyzeToolInput = z.infer; @@ -47,13 +56,16 @@ export async function analyzeToolHandler( const { logger } = context; try { + // Determine the effective image path (prioritize image_path, fallback to path) + const effectiveImagePath = input.image_path || input.path!; + logger.debug( - { input: { ...input, image_path: input.image_path.split("/").pop() } }, + { input: { ...input, effectiveImagePath: effectiveImagePath.split("/").pop() } }, "Processing peekaboo.analyze tool call", ); // Validate image file extension - const ext = path.extname(input.image_path).toLowerCase(); + const ext = path.extname(effectiveImagePath).toLowerCase(); if (![".png", ".jpg", ".jpeg", ".webp"].includes(ext)) { return { content: [ @@ -117,10 +129,10 @@ export async function analyzeToolHandler( // Read image as base64 let imageBase64: string; try { - imageBase64 = await readImageAsBase64(input.image_path); + imageBase64 = await readImageAsBase64(effectiveImagePath); } catch (error) { logger.error( - { error, path: input.image_path }, + { error, path: effectiveImagePath }, "Failed to read image file", ); return { @@ -140,7 +152,7 @@ export async function analyzeToolHandler( try { analysisResult = await analyzeImageWithProvider( { provider, model }, - input.image_path, + effectiveImagePath, imageBase64, input.question, logger, diff --git a/tests/unit/tools/analyze.test.ts b/tests/unit/tools/analyze.test.ts index c4f60cb..87da06a 100644 --- a/tests/unit/tools/analyze.test.ts +++ b/tests/unit/tools/analyze.test.ts @@ -2,6 +2,7 @@ import { vi, beforeEach, describe, it, expect } from "vitest"; import { pino } from "pino"; import { analyzeToolHandler, + analyzeToolSchema, AnalyzeToolInput, } from "../../../src/tools/analyze"; import { readImageAsBase64 } from "../../../src/utils/peekaboo-cli"; @@ -567,4 +568,82 @@ describe("Analyze Tool", () => { expect(result.isError).toBeUndefined(); }); }); + + describe("Schema Validation", () => { + it("should validate successfully with image_path", () => { + const input = { + image_path: "/path/to/image.png", + question: "What is this?", + }; + + const result = analyzeToolSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + it("should validate successfully with path as silent fallback", () => { + const input = { + path: "/path/to/image.png", + question: "What is this?", + }; + + const result = analyzeToolSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + it("should validate successfully when both image_path and path are provided", () => { + const input = { + image_path: "/priority/image.png", + path: "/fallback/image.png", + question: "What is this?", + }; + + const result = analyzeToolSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + it("should fail validation when neither image_path nor path is provided", () => { + const input = { + question: "What is this?", + }; + + const result = analyzeToolSchema.safeParse(input); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errors[0].message).toBe("image_path is required"); + expect(result.error.errors[0].path).toEqual(["image_path"]); + } + }); + + it("should fail validation when question is missing", () => { + const input = { + image_path: "/path/to/image.png", + }; + + const result = analyzeToolSchema.safeParse(input); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errors[0].message).toBe("Required"); + expect(result.error.errors[0].path).toEqual(["question"]); + } + }); + + it("should validate optional provider_config correctly", () => { + const inputWithoutProvider = { + image_path: "/path/to/image.png", + question: "What is this?", + }; + + const inputWithProvider = { + image_path: "/path/to/image.png", + question: "What is this?", + provider_config: { + type: "openai" as const, + model: "gpt-4o", + }, + }; + + expect(analyzeToolSchema.safeParse(inputWithoutProvider).success).toBe(true); + expect(analyzeToolSchema.safeParse(inputWithProvider).success).toBe(true); + }); + }); }); \ No newline at end of file