branch:
schema-conversion.test.ts
31931 bytesRaw
/**
 * Tests for codemode JSON Schema to TypeScript conversion.
 * Dual tests verify both JSON Schema and Zod paths produce identical output
 * through jsonSchemaToTypeString().
 */
import { z } from "zod";
import { jsonSchema } from "ai";
import { describe, it, expect } from "vitest";
import { generateTypes } from "../tool-types";
import type { ToolSet } from "ai";

// Helper: generateTypes accepts ToolDescriptors | ToolSet but jsonSchema() tools
// don't satisfy ToolDescriptors (Zod-typed). Cast via ToolSet for test convenience.
function genTypes(tools: Record<string, unknown>): string {
  return generateTypes(tools as unknown as ToolSet);
}

/**
 * Generates two it() blocks — one using jsonSchema() wrapper, one using Zod —
 * running the same assertions against both. Ensures both schema paths produce
 * identical TypeScript output.
 */
function testBoth(
  name: string,
  toolName: string,
  schemas: { json: Record<string, unknown>; zod: z.ZodType },
  assertions: (result: string) => void,
  options?: {
    description?: string;
    outputSchemas?: { json: Record<string, unknown>; zod: z.ZodType };
  }
): void {
  const desc = options?.description ?? "Test";

  it(`${name} (JSON Schema)`, () => {
    const tools: Record<string, unknown> = {
      [toolName]: {
        description: desc,
        inputSchema: jsonSchema(schemas.json),
        ...(options?.outputSchemas
          ? { outputSchema: jsonSchema(options.outputSchemas.json) }
          : {})
      }
    };
    assertions(genTypes(tools));
  });

  it(`${name} (Zod)`, () => {
    const tools: Record<string, unknown> = {
      [toolName]: {
        description: desc,
        inputSchema: schemas.zod,
        ...(options?.outputSchemas
          ? { outputSchema: options.outputSchemas.zod }
          : {})
      }
    };
    assertions(genTypes(tools));
  });
}

// ---------------------------------------------------------------------------
// 1. Basic types (dual)
// ---------------------------------------------------------------------------

describe("basic types", () => {
  testBoth(
    "simple object with required field",
    "getUser",
    {
      json: {
        type: "object",
        properties: { id: { type: "string" } },
        required: ["id"]
      },
      zod: z.object({ id: z.string() })
    },
    (result) => {
      expect(result).toContain("type GetUserInput");
      expect(result).toContain("id: string;");
      expect(result).toContain("type GetUserOutput = unknown");
    },
    { description: "Get a user" }
  );

  testBoth(
    "nested objects",
    "createOrder",
    {
      json: {
        type: "object",
        properties: {
          user: {
            type: "object",
            properties: {
              name: { type: "string" },
              email: { type: "string" }
            }
          }
        }
      },
      zod: z.object({
        user: z
          .object({
            name: z.string().optional(),
            email: z.string().optional()
          })
          .optional()
      })
    },
    (result) => {
      expect(result).toContain("user?:");
      expect(result).toContain("name?: string;");
      expect(result).toContain("email?: string;");
    },
    { description: "Create an order" }
  );

  testBoth(
    "arrays",
    "search",
    {
      json: {
        type: "object",
        properties: {
          tags: { type: "array", items: { type: "string" } }
        }
      },
      zod: z.object({
        tags: z.array(z.string()).optional()
      })
    },
    (result) => {
      expect(result).toContain("tags?: string[];");
    },
    { description: "Search" }
  );

  testBoth(
    "string enums",
    "sort",
    {
      json: {
        type: "object",
        properties: {
          order: { type: "string", enum: ["asc", "desc"] }
        }
      },
      zod: z.object({
        order: z.enum(["asc", "desc"]).optional()
      })
    },
    (result) => {
      expect(result).toContain('"asc" | "desc"');
    },
    { description: "Sort items" }
  );

  testBoth(
    "required vs optional fields",
    "query",
    {
      json: {
        type: "object",
        properties: {
          query: { type: "string" },
          limit: { type: "number" }
        },
        required: ["query"]
      },
      zod: z.object({
        query: z.string(),
        limit: z.number().optional()
      })
    },
    (result) => {
      expect(result).toContain("query: string;");
      expect(result).toContain("limit?: number;");
    },
    { description: "Query data" }
  );
});

// ---------------------------------------------------------------------------
// 2. Descriptions and JSDoc (dual + JSON-only)
// ---------------------------------------------------------------------------

describe("descriptions and JSDoc", () => {
  testBoth(
    "field descriptions in JSDoc and @param",
    "search",
    {
      json: {
        type: "object",
        properties: {
          query: { type: "string", description: "Search query" },
          limit: { type: "number", description: "Max results" }
        }
      },
      zod: z.object({
        query: z.string().describe("Search query").optional(),
        limit: z.number().describe("Max results").optional()
      })
    },
    (result) => {
      expect(result).toContain("/** Search query */");
      expect(result).toContain("/** Max results */");
      expect(result).toContain("@param input.query - Search query");
      expect(result).toContain("@param input.limit - Max results");
    },
    { description: "Search the web" }
  );

  testBoth(
    "newline normalization in tool descriptions",
    "test",
    {
      json: {
        type: "object",
        properties: { x: { type: "string" } }
      },
      zod: z.object({ x: z.string().optional() })
    },
    (result) => {
      expect(result).toContain(
        "Tool that does multiple things on multiple lines"
      );
    },
    { description: "Tool that does\nmultiple things\r\non multiple lines" }
  );

  testBoth(
    "newline normalization in field descriptions",
    "test",
    {
      json: {
        type: "object",
        properties: {
          field: {
            type: "string",
            description: "Line one\nLine two\r\nLine three"
          }
        }
      },
      zod: z.object({
        field: z
          .string()
          .describe("Line one\nLine two\r\nLine three")
          .optional()
      })
    },
    (result) => {
      expect(result).toContain("/** Line one Line two Line three */");
      expect(result).not.toContain("Line one\n");
    }
  );

  it("escapes */ in property descriptions (JSON-only)", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            field: {
              type: "string" as const,
              description: "Value like */ can break comments"
            }
          }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain("*\\/");
    expect(result).not.toContain("/** Value like */ can");
  });

  it("escapes */ in tool descriptions (JSON-only)", () => {
    const tools = {
      test: {
        description: "A tool with */ in description",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: { x: { type: "string" as const } }
        })
      }
    };

    const result = genTypes(tools);

    expect(result).toContain("*\\/");
    expect(result).not.toMatch(/\* A tool with \*\/ in/);
  });

  it("uses multi-line JSDoc when both description and format are present (JSON-only)", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            email: {
              type: "string" as const,
              description: "User email address",
              format: "email"
            }
          }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain("* User email address");
    expect(result).toContain("* @format email");
    expect(result).not.toContain("/** User email address @format email */");
  });

  it("uses single-line JSDoc when only format is present (JSON-only)", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            id: {
              type: "string" as const,
              format: "uuid"
            }
          }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain("/** @format uuid */");
  });
});

// ---------------------------------------------------------------------------
// 3. Unions and intersections (dual + JSON-only)
// ---------------------------------------------------------------------------

describe("unions and intersections", () => {
  testBoth(
    "anyOf union types",
    "getValue",
    {
      json: {
        type: "object",
        properties: {
          value: {
            anyOf: [{ type: "string" }, { type: "number" }]
          }
        }
      },
      zod: z.object({
        value: z.union([z.string(), z.number()]).optional()
      })
    },
    (result) => {
      expect(result).toContain("string | number");
    },
    { description: "Get value" }
  );

  testBoth(
    "nullable field via anyOf with null",
    "test",
    {
      json: {
        type: "object",
        properties: {
          name: {
            anyOf: [{ type: "string" }, { type: "null" }]
          }
        }
      },
      zod: z.object({
        name: z.string().nullable().optional()
      })
    },
    (result) => {
      expect(result).toContain("string | null");
    }
  );

  it("handles allOf intersection types (JSON-only)", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            val: {
              allOf: [
                {
                  type: "object" as const,
                  properties: { a: { type: "string" as const } }
                },
                {
                  type: "object" as const,
                  properties: { b: { type: "number" as const } }
                }
              ]
            }
          }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain(" & ");
    expect(result).toContain("a?: string;");
    expect(result).toContain("b?: number;");
  });

  it("handles oneOf union types with 3+ members (JSON-only)", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            val: {
              oneOf: [
                { type: "string" as const },
                { type: "number" as const },
                { type: "boolean" as const }
              ]
            }
          }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain("string | number | boolean");
  });
});

// ---------------------------------------------------------------------------
// 4. Output schemas (dual)
// ---------------------------------------------------------------------------

describe("output schemas", () => {
  testBoth(
    "typed output schema",
    "getWeather",
    {
      json: {
        type: "object",
        properties: { city: { type: "string" } }
      },
      zod: z.object({ city: z.string().optional() })
    },
    (result) => {
      expect(result).toContain("type GetWeatherOutput");
      expect(result).not.toContain("GetWeatherOutput = unknown");
      expect(result).toContain("temperature?: number;");
      expect(result).toContain("conditions?: string;");
    },
    {
      description: "Get weather",
      outputSchemas: {
        json: {
          type: "object",
          properties: {
            temperature: { type: "number" },
            conditions: { type: "string" }
          }
        },
        zod: z.object({
          temperature: z.number().optional(),
          conditions: z.string().optional()
        })
      }
    }
  );

  testBoth(
    "complex input+output schemas",
    "getWeather",
    {
      json: {
        type: "object",
        properties: {
          city: { type: "string", description: "City name" },
          units: { type: "string", enum: ["celsius", "fahrenheit"] }
        },
        required: ["city"]
      },
      zod: z.object({
        city: z.string().describe("City name"),
        units: z.enum(["celsius", "fahrenheit"]).optional()
      })
    },
    (result) => {
      // Input
      expect(result).toContain("type GetWeatherInput");
      expect(result).toContain("city: string");
      expect(result).toContain("units?:");
      expect(result).toContain('"celsius"');
      expect(result).toContain('"fahrenheit"');

      // Output
      expect(result).toContain("type GetWeatherOutput");
      expect(result).not.toContain("GetWeatherOutput = unknown");
      expect(result).toContain("temperature");
      expect(result).toContain("conditions");
      expect(result).toContain("forecast?:");
      expect(result).toContain("day?: string");
      expect(result).toContain("high?: number");
      expect(result).toContain("low?: number");

      // JSDoc
      expect(result).toContain("@param input.city - City name");
    },
    {
      description: "Get weather for a city",
      outputSchemas: {
        json: {
          type: "object",
          properties: {
            temperature: { type: "number" },
            conditions: { type: "string" },
            forecast: {
              type: "array",
              items: {
                type: "object",
                properties: {
                  day: { type: "string" },
                  high: { type: "number" },
                  low: { type: "number" }
                }
              }
            }
          },
          required: ["temperature", "conditions"]
        },
        zod: z.object({
          temperature: z.number(),
          conditions: z.string(),
          forecast: z
            .array(
              z.object({
                day: z.string().optional(),
                high: z.number().optional(),
                low: z.number().optional()
              })
            )
            .optional()
        })
      }
    }
  );
});

// ---------------------------------------------------------------------------
// 5. $ref resolution (JSON-only)
// ---------------------------------------------------------------------------

describe("$ref resolution", () => {
  it("resolves $defs refs", () => {
    const tools = {
      create: {
        description: "Create",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            address: { $ref: "#/$defs/Address" }
          },
          $defs: {
            Address: {
              type: "object" as const,
              properties: {
                street: { type: "string" as const },
                city: { type: "string" as const }
              }
            }
          }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain("street?: string;");
    expect(result).toContain("city?: string;");
  });

  it("resolves definitions refs", () => {
    const tools = {
      create: {
        description: "Create",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            item: { $ref: "#/definitions/Item" }
          },
          definitions: {
            Item: {
              type: "object" as const,
              properties: {
                name: { type: "string" as const }
              }
            }
          }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain("name?: string;");
  });

  it("returns unknown for unresolvable ref", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            val: { $ref: "#/definitions/DoesNotExist" }
          }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain("val?: unknown;");
  });

  it("returns unknown for external URL ref", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            val: { $ref: "https://example.com/schema.json" }
          }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain("val?: unknown;");
  });

  it("resolves nested ref chains", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            item: { $ref: "#/$defs/Wrapper" }
          },
          $defs: {
            Wrapper: {
              type: "object" as const,
              properties: {
                inner: { $ref: "#/$defs/Inner" }
              }
            },
            Inner: {
              type: "object" as const,
              properties: {
                value: { type: "number" as const }
              }
            }
          }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain("value?: number;");
  });
});

// ---------------------------------------------------------------------------
// 6. Circular schemas (JSON-only)
// ---------------------------------------------------------------------------

describe("circular schemas", () => {
  it("handles self-referencing $ref without stack overflow", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            child: { $ref: "#" }
          }
        } as Record<string, unknown>)
      }
    };

    // Should not throw
    const result = genTypes(tools);

    expect(result).toContain("type TestInput");
  });

  it("handles deeply nested schemas hitting depth limit", () => {
    // Build a schema 30 levels deep
    let schema: Record<string, unknown> = { type: "string" };
    for (let i = 0; i < 30; i++) {
      schema = {
        type: "object",
        properties: { nested: schema }
      };
    }

    const tools = {
      deep: {
        description: "Deep",
        inputSchema: jsonSchema(schema)
      }
    };

    // Should not throw
    const result = genTypes(tools);

    expect(result).toContain("type DeepInput");
    // At some point it should hit the depth limit and emit `unknown`
    expect(result).toContain("unknown");
  });
});

// ---------------------------------------------------------------------------
// 7. Edge cases (JSON-only)
// ---------------------------------------------------------------------------

describe("edge cases", () => {
  it("maps true schema to unknown and false schema to never", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            anything: true,
            nothing: false
          }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain("anything?: unknown;");
    expect(result).toContain("nothing?: never;");
  });

  it('handles type array like ["string", "null"]', () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            val: { type: ["string", "null"] }
          }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain("string | null");
  });

  it("maps integer to number", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            count: { type: "integer" as const }
          }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain("count?: number;");
  });

  it("handles bare array type without items", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            list: { type: "array" as const }
          }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain("list?: unknown[];");
  });

  it("handles empty enum as never", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            val: { enum: [] }
          }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain("val?: never;");
  });

  it("applies OpenAPI nullable: true to produce union with null", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            name: { type: "string" as const, nullable: true }
          }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain("string | null");
  });
});

// ---------------------------------------------------------------------------
// 8. Property name safety (JSON-only)
// ---------------------------------------------------------------------------

describe("property name safety", () => {
  it("escapes control characters in property names", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            "has\nnewline": { type: "string" as const },
            "has\ttab": { type: "string" as const }
          }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain("\\n");
    expect(result).toContain("\\t");
    expect(result).not.toContain("\n    has\n");
  });

  it("escapes quotes in property names", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            'has"quote': { type: "string" as const }
          }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain('\\"');
  });

  it("handles empty string property name", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            "": { type: "string" as const }
          }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain('""');
  });
});

// ---------------------------------------------------------------------------
// 9. Enum/const values (JSON-only)
// ---------------------------------------------------------------------------

describe("enum/const values", () => {
  it("escapes special chars in enum strings", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            val: {
              type: "string" as const,
              enum: ['say "hello"', "back\\slash"]
            }
          }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain('say \\"hello\\"');
    expect(result).toContain("back\\\\slash");
  });

  it("handles null in enum", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            val: { enum: ["a", null, "b"] }
          }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain('"a" | null | "b"');
  });

  it("escapes special chars in const", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            val: { const: 'line "one"' }
          }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain('line \\"one\\"');
  });

  it("serializes object enum values with JSON.stringify", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            val: { enum: [{ key: "value" }, "plain"] }
          }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain('{"key":"value"}');
    expect(result).not.toContain("[object Object]");
  });

  it("serializes array enum values with JSON.stringify", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            val: { enum: [[1, 2, 3], "plain"] }
          }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain("[1,2,3]");
    expect(result).not.toContain("[object Object]");
  });

  it("serializes object const values with JSON.stringify", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            val: { const: { nested: true } }
          }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain('{"nested":true}');
  });
});

// ---------------------------------------------------------------------------
// 10. additionalProperties (JSON-only)
// ---------------------------------------------------------------------------

describe("additionalProperties", () => {
  it("emits index signature for additionalProperties: true", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            name: { type: "string" as const }
          },
          additionalProperties: true
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain("name?: string;");
    expect(result).toContain("[key: string]: unknown;");
  });

  it("emits typed index signature for typed additionalProperties", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          additionalProperties: { type: "string" as const }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain("[key: string]: string;");
  });

  it("returns empty object type when no properties and additionalProperties is false", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          additionalProperties: false
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain("type TestInput = {}");
    expect(result).not.toContain("Record<string, unknown>");
  });

  it("returns Record<string, unknown> when no properties and no additionalProperties constraint", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const
        })
      }
    };

    const result = genTypes(tools);

    expect(result).toContain("Record<string, unknown>");
  });
});

// ---------------------------------------------------------------------------
// 11. Tuple support (JSON-only)
// ---------------------------------------------------------------------------

describe("tuple support", () => {
  it("handles items as array (draft-07 tuples)", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            pair: {
              type: "array" as const,
              items: [{ type: "string" as const }, { type: "number" as const }]
            }
          }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain("[string, number]");
  });

  it("handles prefixItems (JSON Schema 2020-12)", () => {
    const tools = {
      test: {
        description: "Test",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: {
            triple: {
              type: "array" as const,
              prefixItems: [
                { type: "string" as const },
                { type: "number" as const },
                { type: "boolean" as const }
              ]
            }
          }
        } as Record<string, unknown>)
      }
    };

    const result = genTypes(tools);

    expect(result).toContain("[string, number, boolean]");
  });
});

// ---------------------------------------------------------------------------
// 12. Codemode declaration (dual + JSON-only)
// ---------------------------------------------------------------------------

describe("codemode declaration", () => {
  it("generates proper codemode declaration (JSON Schema)", () => {
    const tools = {
      tool1: {
        description: "First tool",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: { a: { type: "string" as const } }
        })
      },
      tool2: {
        description: "Second tool",
        inputSchema: jsonSchema({
          type: "object" as const,
          properties: { b: { type: "number" as const } }
        })
      }
    };

    const result = genTypes(tools);

    expect(result).toContain("declare const codemode: {");
    expect(result).toContain(
      "tool1: (input: Tool1Input) => Promise<Tool1Output>;"
    );
    expect(result).toContain(
      "tool2: (input: Tool2Input) => Promise<Tool2Output>;"
    );
  });

  it("generates proper codemode declaration (Zod)", () => {
    const tools = {
      tool1: {
        description: "First tool",
        inputSchema: z.object({ a: z.string().optional() })
      },
      tool2: {
        description: "Second tool",
        inputSchema: z.object({ b: z.number().optional() })
      }
    };

    const result = genTypes(tools);

    expect(result).toContain("declare const codemode: {");
    expect(result).toContain(
      "tool1: (input: Tool1Input) => Promise<Tool1Output>;"
    );
    expect(result).toContain(
      "tool2: (input: Tool2Input) => Promise<Tool2Output>;"
    );
  });

  testBoth(
    "tool name sanitization with hyphens",
    "get-user",
    {
      json: {
        type: "object",
        properties: { id: { type: "string" } }
      },
      zod: z.object({ id: z.string().optional() })
    },
    (result) => {
      expect(result).toContain("get_user: (input: GetUserInput)");
    },
    { description: "Get user" }
  );
});