parameters.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. import { describe, expect, test } from "bun:test"
  2. import { Result, Schema } from "effect"
  3. import { ToolJsonSchema } from "../../src/tool/json-schema"
  4. // Each tool exports its parameters schema at module scope so this test can
  5. // import them without running the tool's Effect-based init. The JSON Schema
  6. // snapshot captures what the LLM sees; the parse assertions pin down the
  7. // accepts/rejects contract. `ToolJsonSchema.fromSchema` is the same helper `session/
  8. // prompt.ts` uses to emit tool schemas to the LLM, so the snapshots stay
  9. // provider-compatible while tools use Effect Schema internally.
  10. import { Parameters as ApplyPatch } from "../../src/tool/apply_patch"
  11. import { Parameters as Edit } from "../../src/tool/edit"
  12. import { Parameters as Glob } from "../../src/tool/glob"
  13. import { Parameters as Grep } from "../../src/tool/grep"
  14. import { Parameters as Invalid } from "../../src/tool/invalid"
  15. import { Parameters as Lsp } from "../../src/tool/lsp"
  16. import { Parameters as Plan } from "../../src/tool/plan"
  17. import { Parameters as Question } from "../../src/tool/question"
  18. import { Parameters as Read } from "../../src/tool/read"
  19. import { Parameters as Shell } from "../../src/tool/shell"
  20. import { Parameters as Skill } from "../../src/tool/skill"
  21. import { Parameters as Task } from "../../src/tool/task"
  22. import { Parameters as Todo } from "../../src/tool/todo"
  23. import { Parameters as WebFetch } from "../../src/tool/webfetch"
  24. import { Parameters as WebSearch } from "../../src/tool/websearch"
  25. import { Parameters as Write } from "../../src/tool/write"
  26. const parse = <S extends Schema.Decoder<unknown>>(schema: S, input: unknown): S["Type"] =>
  27. Schema.decodeUnknownSync(schema)(input)
  28. const accepts = (schema: Schema.Decoder<unknown>, input: unknown): boolean =>
  29. Result.isSuccess(Schema.decodeUnknownResult(schema)(input))
  30. const toJsonSchema = ToolJsonSchema.fromSchema
  31. describe("tool parameters", () => {
  32. describe("JSON Schema (wire shape)", () => {
  33. test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot())
  34. test("bash", () => expect(toJsonSchema(Shell)).toMatchSnapshot())
  35. test("edit", () => expect(toJsonSchema(Edit)).toMatchSnapshot())
  36. test("glob", () => expect(toJsonSchema(Glob)).toMatchSnapshot())
  37. test("grep", () => expect(toJsonSchema(Grep)).toMatchSnapshot())
  38. test("invalid", () => expect(toJsonSchema(Invalid)).toMatchSnapshot())
  39. test("lsp", () => expect(toJsonSchema(Lsp)).toMatchSnapshot())
  40. test("plan", () => expect(toJsonSchema(Plan)).toMatchSnapshot())
  41. test("question", () => expect(toJsonSchema(Question)).toMatchSnapshot())
  42. test("read", () => expect(toJsonSchema(Read)).toMatchSnapshot())
  43. test("skill", () => expect(toJsonSchema(Skill)).toMatchSnapshot())
  44. test("task", () => expect(toJsonSchema(Task)).toMatchSnapshot())
  45. test("todo", () => expect(toJsonSchema(Todo)).toMatchSnapshot())
  46. test("webfetch", () => expect(toJsonSchema(WebFetch)).toMatchSnapshot())
  47. test("websearch", () => expect(toJsonSchema(WebSearch)).toMatchSnapshot())
  48. test("write", () => expect(toJsonSchema(Write)).toMatchSnapshot())
  49. test("inlines named child schemas for provider compatibility", () => {
  50. const schema = toJsonSchema(Question)
  51. expect(schema).not.toHaveProperty("$defs")
  52. expect(schema).toMatchObject({
  53. properties: {
  54. questions: { items: { properties: { options: { items: { properties: { label: { type: "string" } } } } } } },
  55. },
  56. })
  57. })
  58. test("preserves required nullable fields", () => {
  59. expect(toJsonSchema(Schema.Struct({ value: Schema.NullOr(Schema.String) }))).toMatchObject({
  60. properties: { value: { anyOf: expect.arrayContaining([{ type: "null" }]) } },
  61. })
  62. })
  63. test("keeps repeated allOf constraints instead of dropping duplicates", () => {
  64. expect(
  65. toJsonSchema(
  66. Schema.Struct({ value: Schema.String.check(Schema.isPattern(/^a/)).check(Schema.isPattern(/z$/)) }),
  67. ),
  68. ).toMatchObject({ properties: { value: { allOf: [{ pattern: "^a" }, { pattern: "z$" }] } } })
  69. })
  70. test("bounds bare integer fields to safe integer range", () => {
  71. expect(toJsonSchema(Schema.Struct({ value: Schema.Int }))).toMatchObject({
  72. properties: { value: { minimum: Number.MIN_SAFE_INTEGER, maximum: Number.MAX_SAFE_INTEGER } },
  73. })
  74. })
  75. test("does not expose defaulted optional keys as nullable", () => {
  76. expect(toJsonSchema(WebFetch)).toMatchObject({
  77. properties: { format: { type: "string", enum: ["text", "markdown", "html"], default: "markdown" } },
  78. })
  79. expect(toJsonSchema(WebFetch).properties?.format).not.toHaveProperty("anyOf")
  80. })
  81. })
  82. describe("apply_patch", () => {
  83. test("accepts patchText", () => {
  84. expect(parse(ApplyPatch, { patchText: "*** Begin Patch\n*** End Patch" })).toEqual({
  85. patchText: "*** Begin Patch\n*** End Patch",
  86. })
  87. })
  88. test("rejects missing patchText", () => {
  89. expect(accepts(ApplyPatch, {})).toBe(false)
  90. })
  91. test("rejects non-string patchText", () => {
  92. expect(accepts(ApplyPatch, { patchText: 123 })).toBe(false)
  93. })
  94. })
  95. describe("shell", () => {
  96. test("accepts command", () => {
  97. expect(parse(Shell, { command: "ls" })).toEqual({ command: "ls" })
  98. })
  99. test("accepts optional timeout + workdir", () => {
  100. const parsed = parse(Shell, { command: "ls", timeout: 5000, workdir: "/tmp" })
  101. expect(parsed.timeout).toBe(5000)
  102. expect(parsed.workdir).toBe("/tmp")
  103. })
  104. test("rejects missing command", () => {
  105. expect(accepts(Shell, {})).toBe(false)
  106. })
  107. })
  108. describe("edit", () => {
  109. test("accepts all four fields", () => {
  110. expect(parse(Edit, { filePath: "/a", oldString: "x", newString: "y", replaceAll: true })).toEqual({
  111. filePath: "/a",
  112. oldString: "x",
  113. newString: "y",
  114. replaceAll: true,
  115. })
  116. })
  117. test("replaceAll is optional", () => {
  118. const parsed = parse(Edit, { filePath: "/a", oldString: "x", newString: "y" })
  119. expect(parsed.replaceAll).toBeUndefined()
  120. })
  121. test("rejects missing filePath", () => {
  122. expect(accepts(Edit, { oldString: "x", newString: "y" })).toBe(false)
  123. })
  124. })
  125. describe("glob", () => {
  126. test("accepts pattern-only", () => {
  127. expect(parse(Glob, { pattern: "**/*.ts" })).toEqual({ pattern: "**/*.ts" })
  128. })
  129. test("accepts optional path", () => {
  130. expect(parse(Glob, { pattern: "**/*.ts", path: "/tmp" }).path).toBe("/tmp")
  131. })
  132. test("rejects missing pattern", () => {
  133. expect(accepts(Glob, {})).toBe(false)
  134. })
  135. })
  136. describe("grep", () => {
  137. test("accepts pattern-only", () => {
  138. expect(parse(Grep, { pattern: "TODO" })).toEqual({ pattern: "TODO" })
  139. })
  140. test("accepts optional path + include", () => {
  141. const parsed = parse(Grep, { pattern: "TODO", path: "/tmp", include: "*.ts" })
  142. expect(parsed.path).toBe("/tmp")
  143. expect(parsed.include).toBe("*.ts")
  144. })
  145. test("rejects missing pattern", () => {
  146. expect(accepts(Grep, {})).toBe(false)
  147. })
  148. })
  149. describe("invalid", () => {
  150. test("accepts tool + error", () => {
  151. expect(parse(Invalid, { tool: "foo", error: "bar" })).toEqual({ tool: "foo", error: "bar" })
  152. })
  153. test("rejects missing fields", () => {
  154. expect(accepts(Invalid, { tool: "foo" })).toBe(false)
  155. expect(accepts(Invalid, { error: "bar" })).toBe(false)
  156. })
  157. })
  158. describe("lsp", () => {
  159. test("accepts all fields", () => {
  160. const parsed = parse(Lsp, { operation: "hover", filePath: "/a.ts", line: 1, character: 1 })
  161. expect(parsed.operation).toBe("hover")
  162. })
  163. test("rejects line < 1", () => {
  164. expect(accepts(Lsp, { operation: "hover", filePath: "/a.ts", line: 0, character: 1 })).toBe(false)
  165. })
  166. test("rejects character < 1", () => {
  167. expect(accepts(Lsp, { operation: "hover", filePath: "/a.ts", line: 1, character: 0 })).toBe(false)
  168. })
  169. test("rejects unknown operation", () => {
  170. expect(accepts(Lsp, { operation: "bogus", filePath: "/a.ts", line: 1, character: 1 })).toBe(false)
  171. })
  172. })
  173. describe("plan", () => {
  174. test("accepts empty object", () => {
  175. expect(parse(Plan, {})).toEqual({})
  176. })
  177. })
  178. describe("question", () => {
  179. test("accepts questions array", () => {
  180. const parsed = parse(Question, {
  181. questions: [
  182. {
  183. question: "pick one",
  184. header: "Header",
  185. custom: false,
  186. options: [{ label: "a", description: "desc" }],
  187. },
  188. ],
  189. })
  190. expect(parsed.questions.length).toBe(1)
  191. })
  192. test("rejects missing questions", () => {
  193. expect(accepts(Question, {})).toBe(false)
  194. })
  195. })
  196. describe("read", () => {
  197. test("accepts filePath-only", () => {
  198. expect(parse(Read, { filePath: "/a" }).filePath).toBe("/a")
  199. })
  200. test("accepts optional offset + limit", () => {
  201. const parsed = parse(Read, { filePath: "/a", offset: 10, limit: 100 })
  202. expect(parsed.offset).toBe(10)
  203. expect(parsed.limit).toBe(100)
  204. })
  205. })
  206. describe("skill", () => {
  207. test("accepts name", () => {
  208. expect(parse(Skill, { name: "foo" }).name).toBe("foo")
  209. })
  210. test("rejects missing name", () => {
  211. expect(accepts(Skill, {})).toBe(false)
  212. })
  213. })
  214. describe("task", () => {
  215. test("accepts description + prompt + subagent_type", () => {
  216. const parsed = parse(Task, { description: "d", prompt: "p", subagent_type: "general" })
  217. expect(parsed.subagent_type).toBe("general")
  218. })
  219. test("accepts optional background flag", () => {
  220. const parsed = parse(Task, { description: "d", prompt: "p", subagent_type: "general", background: true })
  221. expect(parsed.background).toBe(true)
  222. })
  223. test("rejects missing prompt", () => {
  224. expect(accepts(Task, { description: "d", subagent_type: "general" })).toBe(false)
  225. })
  226. })
  227. describe("todo", () => {
  228. test("accepts todos array", () => {
  229. const parsed = parse(Todo, {
  230. todos: [{ id: "t1", content: "do x", status: "pending", priority: "medium" }],
  231. })
  232. expect(parsed.todos.length).toBe(1)
  233. })
  234. test("rejects missing todos", () => {
  235. expect(accepts(Todo, {})).toBe(false)
  236. })
  237. })
  238. describe("webfetch", () => {
  239. test("defaults omitted format to markdown", () => {
  240. expect(parse(WebFetch, { url: "https://example.com" })).toEqual({
  241. url: "https://example.com",
  242. format: "markdown",
  243. })
  244. expect(parse(WebFetch, { url: "https://example.com", format: undefined })).toEqual({
  245. url: "https://example.com",
  246. format: "markdown",
  247. })
  248. })
  249. })
  250. describe("websearch", () => {
  251. test("accepts query", () => {
  252. expect(parse(WebSearch, { query: "kirincode" }).query).toBe("kirincode")
  253. })
  254. })
  255. describe("write", () => {
  256. test("accepts content + filePath", () => {
  257. expect(parse(Write, { content: "hi", filePath: "/a" })).toEqual({ content: "hi", filePath: "/a" })
  258. })
  259. test("rejects missing filePath", () => {
  260. expect(accepts(Write, { content: "hi" })).toBe(false)
  261. })
  262. })
  263. })