tool-schema.test.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. import { expect, test } from "bun:test"
  2. import { Effect, Schema } from "effect"
  3. import type { Info } from "@opencode-ai/schema/tool"
  4. import { Tool } from "../src/tool"
  5. import { definition, execute } from "../src/tool/runtime"
  6. test("tools are structural values", async () => {
  7. const config = {
  8. name: "foreign",
  9. description: "Foreign tool",
  10. input: Schema.Struct({ value: Schema.String }),
  11. output: Schema.Struct({ ok: Schema.Boolean }),
  12. execute: () => Effect.succeed({ output: { ok: true } }),
  13. }
  14. const tool: Info = config
  15. expect(definition(tool)).toEqual({
  16. name: "foreign",
  17. description: "Foreign tool",
  18. inputSchema: {
  19. type: "object",
  20. properties: { value: { type: "string" } },
  21. required: ["value"],
  22. additionalProperties: false,
  23. },
  24. outputSchema: {
  25. type: "object",
  26. properties: { ok: { type: "boolean" } },
  27. required: ["ok"],
  28. additionalProperties: false,
  29. },
  30. })
  31. })
  32. test("Effect tool schemas use exact optional keys and flatten compatible constraints", () => {
  33. const tool: Info = {
  34. name: "constraints",
  35. description: "Constraints",
  36. input: Schema.Struct({
  37. offset: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
  38. code: Schema.String.check(Schema.isPattern(/^a/), Schema.isPattern(/z$/)),
  39. }),
  40. execute: () => Effect.succeed({ content: "unused" }),
  41. }
  42. expect(definition(tool).inputSchema).toEqual({
  43. type: "object",
  44. properties: {
  45. offset: { type: "integer", minimum: 0 },
  46. code: { type: "string", allOf: [{ pattern: "^a" }, { pattern: "z$" }] },
  47. },
  48. required: ["code"],
  49. additionalProperties: false,
  50. })
  51. })
  52. test("Effect tool schemas inline named child schemas", () => {
  53. const Child = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Child" })
  54. const tool: Info = {
  55. name: "references",
  56. description: "References",
  57. input: Schema.Struct({ child: Child.annotate({ description: "Child value" }) }),
  58. execute: () => Effect.succeed({ content: "unused" }),
  59. }
  60. expect(definition(tool).inputSchema).toEqual({
  61. type: "object",
  62. properties: {
  63. child: {
  64. type: "object",
  65. properties: { value: { type: "string" } },
  66. required: ["value"],
  67. additionalProperties: false,
  68. description: "Child value",
  69. },
  70. },
  71. required: ["child"],
  72. additionalProperties: false,
  73. })
  74. })
  75. test("Effect tool schemas resolve escaped definition names", () => {
  76. const Slash = Schema.Struct({ slash: Schema.String }).annotate({ identifier: "A/B" })
  77. const Tilde = Schema.Struct({ tilde: Schema.String }).annotate({ identifier: "A~B" })
  78. const tool: Info = {
  79. name: "escaped-references",
  80. description: "Escaped references",
  81. input: Schema.Struct({ slash: Slash, tilde: Tilde }),
  82. execute: () => Effect.succeed({ content: "unused" }),
  83. }
  84. expect(JSON.stringify(definition(tool).inputSchema)).not.toContain("$ref")
  85. expect(JSON.stringify(definition(tool).inputSchema)).not.toContain("$defs")
  86. })
  87. test("portable schemas validate and describe typed tools", async () => {
  88. const input = {
  89. "~standard": {
  90. version: 1,
  91. vendor: "test",
  92. validate: (value: unknown) => {
  93. if (typeof value !== "object" || value === null || !("count" in value) || typeof value.count !== "string")
  94. return { issues: [{ message: "count must be numeric" }] }
  95. const count = Number(value.count)
  96. return Number.isFinite(count) ? { value: { count } } : { issues: [{ message: "count must be numeric" }] }
  97. },
  98. jsonSchema: {
  99. input: () => ({ type: "object", properties: { count: { type: "string" } } }),
  100. output: () => ({ type: "object", properties: { count: { type: "number" } } }),
  101. },
  102. },
  103. }
  104. const output = {
  105. "~standard": {
  106. version: 1,
  107. vendor: "test",
  108. validate: (value: unknown) => ({ value: String(value) }),
  109. jsonSchema: {
  110. input: () => ({ type: "number" }),
  111. output: () => ({ type: "string" }),
  112. },
  113. },
  114. }
  115. const tool: Info = ({
  116. name: "portable",
  117. description: "Portable tool",
  118. input,
  119. output,
  120. execute: ({ count }) => Effect.succeed({ output: count + 1 }),
  121. })
  122. expect(definition(tool)).toEqual({
  123. name: "portable",
  124. description: "Portable tool",
  125. inputSchema: { type: "object", properties: { count: { type: "string" } } },
  126. outputSchema: { type: "string" },
  127. })
  128. const result = await Effect.runPromise(execute(tool, { count: "41" }, {} as Tool.Context))
  129. expect(result.output).toBe("42")
  130. })
  131. test("portable schema failures become tool failures", async () => {
  132. const input = {
  133. "~standard": {
  134. version: 1,
  135. vendor: "test",
  136. validate: (_value: unknown) => ({ issues: [{ message: "expected a string" }] }),
  137. jsonSchema: {
  138. input: () => ({ type: "string" }),
  139. output: () => ({ type: "string" }),
  140. },
  141. },
  142. }
  143. const error = await Effect.runPromiseExit(
  144. execute(
  145. {
  146. name: "invalid",
  147. description: "Invalid",
  148. input,
  149. execute: () => Effect.succeed({ content: "unused" }),
  150. },
  151. 1,
  152. {} as Tool.Context,
  153. ),
  154. )
  155. expect(error.toString()).toContain("Invalid tool input: expected a string")
  156. })
  157. test("canonical results carry metadata with typed output", async () => {
  158. const input = Schema.Struct({ value: Schema.String })
  159. const output = Schema.Struct({ value: Schema.String, internal: Schema.Boolean })
  160. const tool: Info = ({
  161. name: "annotated",
  162. description: "Annotated tool",
  163. input,
  164. output,
  165. execute: ({ value }) => Effect.succeed({ output: { value, internal: true }, metadata: { value }, content: value }),
  166. })
  167. expect(await Effect.runPromise(tool.execute({ value: "out" }, {} as Tool.Context))).toEqual({
  168. output: { value: "out", internal: true },
  169. metadata: { value: "out" },
  170. content: "out",
  171. })
  172. })
  173. test("raw JSON schemas are render-only and omitted output means model-only", async () => {
  174. const input = { type: "object", properties: { value: { type: "string" } } }
  175. const tool: Info = ({
  176. name: "raw",
  177. description: "Raw tool",
  178. input,
  179. execute: (input) => Effect.succeed({ content: JSON.stringify(input) }),
  180. })
  181. expect(definition(tool)).toEqual({
  182. name: "raw",
  183. description: "Raw tool",
  184. inputSchema: { type: "object", properties: { value: { type: "string" } } },
  185. })
  186. expect(await Effect.runPromise(execute(tool, { value: 1 }, {} as Tool.Context))).toEqual({
  187. output: undefined,
  188. content: [{ type: "text", text: '{"value":1}' }],
  189. })
  190. })
  191. test("missing external input schemas fall back to an empty schema", () => {
  192. const tool = {
  193. name: "external",
  194. description: "External tool",
  195. input: undefined,
  196. execute: () => Effect.succeed({ content: "unused" }),
  197. } as unknown as Info
  198. expect(definition(tool)).toEqual({
  199. name: "external",
  200. description: "External tool",
  201. inputSchema: {},
  202. })
  203. })