signature.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. import { describe, expect, test } from "bun:test"
  2. import { Effect, Schema } from "effect"
  3. import { CodeMode, Tool } from "../src/index.js"
  4. import { inputTypeScript, jsonSchemaToTypeScript, outputTypeScript } from "../src/tool-schema.js"
  5. // A raw JSON Schema tool in the shape an MCP adapter produces: render-only input schema
  6. // whose property descriptions and constraints must surface as JSDoc in pretty signatures.
  7. const listIssues = Tool.make({
  8. description: "List issues in a repository",
  9. input: {
  10. type: "object",
  11. properties: {
  12. owner: { type: "string", description: "Repository owner" },
  13. after: { type: "string", description: "Cursor from the previous response's pageInfo" },
  14. perPage: { type: "number", description: "Results per page", default: 30 },
  15. labels: { type: "array", items: { type: "string" }, description: "Filter by labels", minItems: 1, maxItems: 10 },
  16. state: { type: "string", enum: ["open", "closed"] },
  17. },
  18. required: ["owner"],
  19. },
  20. output: {},
  21. execute: () => Effect.succeed("[]"),
  22. })
  23. // An Effect Schema tool whose field annotations must flow through the emitted JSON Schema.
  24. const lookupOrder = Tool.make({
  25. description: "Look up an order",
  26. input: Schema.Struct({
  27. id: Schema.String.annotate({ description: "Order identifier" }),
  28. verbose: Schema.optionalKey(Schema.Boolean),
  29. }),
  30. output: Schema.Struct({
  31. status: Schema.String.annotate({ description: "Current order status" }),
  32. }),
  33. execute: () => Effect.succeed({ status: "open" }),
  34. })
  35. describe("pretty signature rendering", () => {
  36. test("described fields get JSDoc comments; undescribed and untagged fields get none", () => {
  37. expect(inputTypeScript(listIssues, true)).toBe(
  38. [
  39. "{",
  40. " /** Repository owner */",
  41. " owner: string,",
  42. " /** Cursor from the previous response's pageInfo */",
  43. " after?: string,",
  44. " /**",
  45. " * Results per page",
  46. " * @default 30",
  47. " */",
  48. " perPage?: number,",
  49. " /**",
  50. " * Filter by labels",
  51. " * @minItems 1",
  52. " * @maxItems 10",
  53. " */",
  54. " labels?: Array<string>,",
  55. ' state?: "open" | "closed",',
  56. "}",
  57. ].join("\n"),
  58. )
  59. })
  60. test("compact mode output is unchanged by the pretty machinery", () => {
  61. expect(inputTypeScript(listIssues)).toBe(
  62. '{ owner: string; after?: string; perPage?: number; labels?: Array<string>; state?: "open" | "closed" }',
  63. )
  64. expect(inputTypeScript(lookupOrder)).toBe("{ id: string; verbose?: boolean }")
  65. expect(outputTypeScript(lookupOrder)).toBe("{ status: string }")
  66. })
  67. test("nested objects recurse with increasing indent and their own JSDoc", () => {
  68. const pretty = jsonSchemaToTypeScript(
  69. {
  70. type: "object",
  71. properties: {
  72. filter: {
  73. type: "object",
  74. description: "Search filter",
  75. properties: { state: { type: "string", description: "Issue state" } },
  76. },
  77. },
  78. },
  79. true,
  80. )
  81. expect(pretty).toBe(
  82. [
  83. "{",
  84. " /** Search filter */",
  85. " filter?: {",
  86. " /** Issue state */",
  87. " state?: string,",
  88. " },",
  89. "}",
  90. ].join("\n"),
  91. )
  92. })
  93. test("Effect Schema annotations become JSDoc on input and output fields", () => {
  94. expect(inputTypeScript(lookupOrder, true)).toBe(
  95. ["{", " /** Order identifier */", " id: string,", " verbose?: boolean,", "}"].join("\n"),
  96. )
  97. expect(outputTypeScript(lookupOrder, true)).toBe(
  98. ["{", " /** Current order status */", " status: string,", "}"].join("\n"),
  99. )
  100. })
  101. test("constraints TypeScript cannot express surface as JSDoc tags", () => {
  102. const pretty = jsonSchemaToTypeScript(
  103. {
  104. type: "object",
  105. properties: {
  106. legacy: { type: "string", deprecated: true },
  107. homepage: { type: "string", format: "uri" },
  108. tags: { type: "array", items: { type: "string" }, minItems: 2, maxItems: 5, default: ["a", "b"] },
  109. },
  110. },
  111. true,
  112. )
  113. expect(pretty).toContain(" /** @deprecated */\n legacy?: string")
  114. expect(pretty).toContain(" /** @format uri */\n homepage?: string")
  115. expect(pretty).toContain(
  116. [
  117. " /**",
  118. ' * @default ["a","b"]',
  119. " * @minItems 2",
  120. " * @maxItems 5",
  121. " */",
  122. " tags?: Array<string>",
  123. ].join("\n"),
  124. )
  125. })
  126. test("skips an unserializable default rather than emitting a broken tag", () => {
  127. const pretty = jsonSchemaToTypeScript(
  128. { type: "object", properties: { size: { type: "number", default: 1n } } },
  129. true,
  130. )
  131. expect(pretty).toBe(["{", " size?: number,", "}"].join("\n"))
  132. })
  133. test("neutralizes */ inside descriptions so nothing closes the comment early", () => {
  134. const pretty = jsonSchemaToTypeScript(
  135. { type: "object", properties: { note: { type: "string", description: "Ends */ early" } } },
  136. true,
  137. )
  138. expect(pretty).toContain(" /** Ends * / early */")
  139. expect(pretty).not.toContain("Ends */")
  140. })
  141. test("multiline descriptions become *-prefixed blocks with blank edges trimmed", () => {
  142. const pretty = jsonSchemaToTypeScript(
  143. {
  144. type: "object",
  145. properties: { query: { type: "string", description: "\nFirst line\n\nSecond line\n" } },
  146. },
  147. true,
  148. )
  149. expect(pretty).toBe(
  150. ["{", " /**", " * First line", " *", " * Second line", " */", " query?: string,", "}"].join("\n"),
  151. )
  152. })
  153. test("stays total on cyclic $refs and pathological nesting in both modes", () => {
  154. const cyclic = {
  155. $ref: "#/$defs/Node",
  156. $defs: { Node: { type: "object", properties: { child: { $ref: "#/$defs/Node" }, name: { type: "string" } } } },
  157. } as const
  158. expect(jsonSchemaToTypeScript(cyclic)).toBe("{ child?: unknown; name?: string }")
  159. expect(jsonSchemaToTypeScript(cyclic, true)).toContain("child?: unknown")
  160. let deep: Record<string, unknown> = { type: "string" }
  161. for (let level = 0; level < 12; level += 1) deep = { type: "object", properties: { next: deep } }
  162. for (const pretty of [false, true]) {
  163. const rendered = jsonSchemaToTypeScript(deep, pretty)
  164. expect(rendered).toContain("unknown")
  165. expect(rendered).toContain("next?:")
  166. }
  167. })
  168. test("intersects ref and union siblings instead of discarding them", () => {
  169. expect(
  170. jsonSchemaToTypeScript({
  171. $ref: "#/$defs/User",
  172. properties: { active: { type: "boolean" } },
  173. required: ["active"],
  174. $defs: {
  175. User: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
  176. },
  177. }),
  178. ).toBe("{ id: string } & { active: boolean }")
  179. expect(
  180. jsonSchemaToTypeScript({
  181. type: "object",
  182. properties: { common: { type: "boolean" } },
  183. required: ["common"],
  184. anyOf: [
  185. { type: "object", properties: { name: { type: "string" } }, required: ["name"] },
  186. { type: "object", properties: { count: { type: "number" } }, required: ["count"] },
  187. ],
  188. }),
  189. ).toBe("({ name: string } | { count: number }) & { common: boolean }")
  190. expect(jsonSchemaToTypeScript({ $ref: "https://example.com/schema.json" })).toBe("unknown")
  191. expect(
  192. jsonSchemaToTypeScript({
  193. $ref: "#/$defs/User/properties/id",
  194. $defs: { User: { type: "object" }, id: { type: "string" } },
  195. }),
  196. ).toBe("unknown")
  197. expect(
  198. jsonSchemaToTypeScript({
  199. type: ["object", "null"],
  200. properties: { name: { type: "string" } },
  201. }),
  202. ).toBe("{ name?: string } | null")
  203. })
  204. })
  205. describe("non-identifier property names render as quoted keys", () => {
  206. // MCP-style schemas routinely carry property names that are not bare TS identifiers
  207. // (`foo-bar`, `@type`, dotted names); the rendered signature must quote them so the
  208. // model sees a valid TypeScript object type. Bare identifiers stay unquoted.
  209. const rawSchema = {
  210. type: "object",
  211. properties: {
  212. "foo-bar": { type: "string" },
  213. "@type": { type: "string" },
  214. "x.y": { type: "number", description: "Dotted name" },
  215. "123": { type: "number" },
  216. plain: { type: "boolean" },
  217. },
  218. required: ["@type"],
  219. } as const
  220. test("compact rendering quotes non-identifier keys and leaves identifiers bare", () => {
  221. expect(jsonSchemaToTypeScript(rawSchema)).toBe(
  222. '{ "123"?: number; "foo-bar"?: string; "@type": string; "x.y"?: number; plain?: boolean }',
  223. )
  224. })
  225. test("pretty rendering quotes non-identifier keys and keeps their JSDoc", () => {
  226. expect(jsonSchemaToTypeScript(rawSchema, true)).toBe(
  227. [
  228. "{",
  229. ' "123"?: number,',
  230. ' "foo-bar"?: string,',
  231. ' "@type": string,',
  232. " /** Dotted name */",
  233. ' "x.y"?: number,',
  234. " plain?: boolean,",
  235. "}",
  236. ].join("\n"),
  237. )
  238. })
  239. test("JSON Schema input and output signatures of a tool both quote", () => {
  240. const tool = Tool.make({
  241. description: "Adapter tool with awkward field names",
  242. input: rawSchema,
  243. output: {
  244. type: "object",
  245. properties: { "content-type": { type: "string" } },
  246. required: ["content-type"],
  247. } as const,
  248. execute: () => Effect.succeed({ "content-type": "text/plain" }),
  249. })
  250. expect(inputTypeScript(tool)).toContain('"foo-bar"?: string')
  251. expect(outputTypeScript(tool)).toBe('{ "content-type": string }')
  252. expect(outputTypeScript(tool, true)).toBe(["{", ' "content-type": string,', "}"].join("\n"))
  253. })
  254. test("Effect Schema structs with non-identifier field names quote too", () => {
  255. const tool = Tool.make({
  256. description: "Schema tool with awkward field names",
  257. input: Schema.Struct({ "foo-bar": Schema.String, plain: Schema.optionalKey(Schema.Number) }),
  258. execute: () => Effect.succeed(null),
  259. })
  260. expect(inputTypeScript(tool)).toBe('{ "foo-bar": string; plain?: number }')
  261. expect(inputTypeScript(tool, true)).toBe(["{", ' "foo-bar": string,', " plain?: number,", "}"].join("\n"))
  262. })
  263. })
  264. describe("union schemas render every alternative", () => {
  265. test("anyOf with a number branch keeps sibling alternatives", () => {
  266. const schema = {
  267. anyOf: [{ type: "string" }, { type: "number" }],
  268. } as const
  269. expect(jsonSchemaToTypeScript(schema)).toBe("string | number")
  270. expect(jsonSchemaToTypeScript(schema, true)).toBe("string | number")
  271. })
  272. test("nullable numeric unions keep null", () => {
  273. const schema = {
  274. oneOf: [{ type: "number" }, { type: "null" }],
  275. } as const
  276. expect(jsonSchemaToTypeScript(schema)).toBe("number | null")
  277. expect(jsonSchemaToTypeScript(schema, true)).toBe("number | null")
  278. })
  279. test("tool input and output signatures preserve numeric unions", () => {
  280. const tool = Tool.make({
  281. description: "Tool with numeric unions",
  282. input: {
  283. type: "object",
  284. properties: {
  285. value: { anyOf: [{ type: "string" }, { type: "number" }] },
  286. },
  287. } as const,
  288. output: { anyOf: [{ type: "number" }, { type: "boolean" }] } as const,
  289. execute: () => Effect.succeed(1),
  290. })
  291. expect(inputTypeScript(tool)).toBe("{ value?: string | number }")
  292. expect(outputTypeScript(tool)).toBe("number | boolean")
  293. })
  294. test("allOf renders intersections with parenthesized union members", () => {
  295. const schema = {
  296. allOf: [{ type: "object", properties: { id: { type: "string" } } }, { type: ["string", "null"] }],
  297. } as const
  298. expect(jsonSchemaToTypeScript(schema)).toBe("{ id?: string } & (string | null)")
  299. })
  300. test("allOf does not discard an unresolved constraint", () => {
  301. expect(jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { $ref: "https://example.com/external.json" }] })).toBe(
  302. "unknown",
  303. )
  304. expect(
  305. jsonSchemaToTypeScript({
  306. allOf: [{ type: "string" }, { allOf: [{ $ref: "https://example.com/external.json" }] }],
  307. }),
  308. ).toBe("unknown")
  309. expect(
  310. jsonSchemaToTypeScript({
  311. type: "string",
  312. allOf: [{ $ref: "#/$defs/Constraint" }],
  313. $defs: { Constraint: { description: "TypeScript-neutral constraint" } },
  314. }),
  315. ).toBe("string")
  316. })
  317. })
  318. describe("JSDoc signatures in catalogs and search results", () => {
  319. const runtime = CodeMode.make({ tools: { github: { list_issues: listIssues }, orders: { lookup: lookupOrder } } })
  320. const search = async (query: string) => {
  321. const result = await Effect.runPromise(runtime.execute(`return search({ query: ${JSON.stringify(query)} })`))
  322. expect(result.ok).toBe(true)
  323. if (!result.ok) throw new Error("search failed")
  324. return result.value as { items: Array<{ path: string; signature: string }>; remaining: number }
  325. }
  326. test("a raw JSON Schema (MCP-style) tool's result signature carries field JSDoc and tags", async () => {
  327. const { items } = await search("list issues repository")
  328. const item = items.find(({ path }) => path === "tools.github.list_issues")!
  329. expect(item.signature).toBe(
  330. [
  331. "tools.github.list_issues(input: {",
  332. " /** Repository owner */",
  333. " owner: string,",
  334. " /** Cursor from the previous response's pageInfo */",
  335. " after?: string,",
  336. " /**",
  337. " * Results per page",
  338. " * @default 30",
  339. " */",
  340. " perPage?: number,",
  341. " /**",
  342. " * Filter by labels",
  343. " * @minItems 1",
  344. " * @maxItems 10",
  345. " */",
  346. " labels?: Array<string>,",
  347. ' state?: "open" | "closed",',
  348. "}): Promise<unknown>",
  349. ].join("\n"),
  350. )
  351. })
  352. test("an annotated Effect Schema tool's result signature carries field JSDoc (exact-path lookup too)", async () => {
  353. for (const query of ["look up order", "tools.orders.lookup"]) {
  354. const { items } = await search(query)
  355. const item = items.find(({ path }) => path === "tools.orders.lookup")!
  356. expect(item.signature).toBe(
  357. [
  358. "tools.orders.lookup(input: {",
  359. " /** Order identifier */",
  360. " id: string,",
  361. " verbose?: boolean,",
  362. "}): Promise<{",
  363. " /** Current order status */",
  364. " status: string,",
  365. "}>",
  366. ].join("\n"),
  367. )
  368. }
  369. })
  370. test("the catalog uses the same JSDoc signatures as search", async () => {
  371. const catalog = runtime.catalog()
  372. const github = (await search("list issues repository")).items.find(
  373. ({ path }) => path === "tools.github.list_issues",
  374. )!
  375. const orders = (await search("look up order")).items.find(({ path }) => path === "tools.orders.lookup")!
  376. expect(catalog.map(({ signature }) => signature)).toContain(github.signature)
  377. expect(catalog.map(({ signature }) => signature)).toContain(orders.signature)
  378. expect(github.signature).toContain("/** Repository owner */")
  379. })
  380. })
  381. describe("non-identifier tool paths", () => {
  382. const resolveLibrary = Tool.make({
  383. description: "Resolve a Context7 library ID",
  384. input: {
  385. type: "object",
  386. properties: {
  387. query: { type: "string" },
  388. libraryName: { type: "string" },
  389. },
  390. required: ["query", "libraryName"],
  391. } as const,
  392. output: {},
  393. execute: () => Effect.succeed("/reactjs/react.dev"),
  394. })
  395. const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } })
  396. test("catalog signatures use bracket notation for dashed tool names", () => {
  397. expect(runtime.catalog()[0]?.signature).toBe(
  398. 'tools.context7["resolve-library-id"](input: {\n query: string,\n libraryName: string,\n}): Promise<unknown>',
  399. )
  400. })
  401. test("search results return callable bracket-notation paths and signatures", async () => {
  402. const result = await Effect.runPromise(runtime.execute(`return search({ query: "resolve library" })`))
  403. expect(result.ok).toBe(true)
  404. if (!result.ok) throw new Error("search failed")
  405. const value = result.value as { items: Array<{ path: string; signature: string }> }
  406. expect(value.items[0]?.path).toBe('tools.context7["resolve-library-id"]')
  407. expect(value.items[0]?.signature).toContain('tools.context7["resolve-library-id"](input: {')
  408. })
  409. })