signature.test.ts 16 KB

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