structured-output.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. import { describe, expect, test } from "bun:test"
  2. import { MessageV2 } from "../../src/session/message-v2"
  3. import { SessionPrompt } from "../../src/session/prompt"
  4. describe("structured-output.OutputFormat", () => {
  5. test("parses text format", () => {
  6. const result = MessageV2.Format.safeParse({ type: "text" })
  7. expect(result.success).toBe(true)
  8. if (result.success) {
  9. expect(result.data.type).toBe("text")
  10. }
  11. })
  12. test("parses json_schema format with defaults", () => {
  13. const result = MessageV2.Format.safeParse({
  14. type: "json_schema",
  15. schema: { type: "object", properties: { name: { type: "string" } } },
  16. })
  17. expect(result.success).toBe(true)
  18. if (result.success) {
  19. expect(result.data.type).toBe("json_schema")
  20. if (result.data.type === "json_schema") {
  21. expect(result.data.retryCount).toBe(2) // default value
  22. }
  23. }
  24. })
  25. test("parses json_schema format with custom retryCount", () => {
  26. const result = MessageV2.Format.safeParse({
  27. type: "json_schema",
  28. schema: { type: "object" },
  29. retryCount: 5,
  30. })
  31. expect(result.success).toBe(true)
  32. if (result.success && result.data.type === "json_schema") {
  33. expect(result.data.retryCount).toBe(5)
  34. }
  35. })
  36. test("rejects invalid type", () => {
  37. const result = MessageV2.Format.safeParse({ type: "invalid" })
  38. expect(result.success).toBe(false)
  39. })
  40. test("rejects json_schema without schema", () => {
  41. const result = MessageV2.Format.safeParse({ type: "json_schema" })
  42. expect(result.success).toBe(false)
  43. })
  44. test("rejects negative retryCount", () => {
  45. const result = MessageV2.Format.safeParse({
  46. type: "json_schema",
  47. schema: { type: "object" },
  48. retryCount: -1,
  49. })
  50. expect(result.success).toBe(false)
  51. })
  52. })
  53. describe("structured-output.StructuredOutputError", () => {
  54. test("creates error with message and retries", () => {
  55. const error = new MessageV2.StructuredOutputError({
  56. message: "Failed to validate",
  57. retries: 3,
  58. })
  59. expect(error.name).toBe("StructuredOutputError")
  60. expect(error.data.message).toBe("Failed to validate")
  61. expect(error.data.retries).toBe(3)
  62. })
  63. test("converts to object correctly", () => {
  64. const error = new MessageV2.StructuredOutputError({
  65. message: "Test error",
  66. retries: 2,
  67. })
  68. const obj = error.toObject()
  69. expect(obj.name).toBe("StructuredOutputError")
  70. expect(obj.data.message).toBe("Test error")
  71. expect(obj.data.retries).toBe(2)
  72. })
  73. test("isInstance correctly identifies error", () => {
  74. const error = new MessageV2.StructuredOutputError({
  75. message: "Test",
  76. retries: 1,
  77. })
  78. expect(MessageV2.StructuredOutputError.isInstance(error)).toBe(true)
  79. expect(MessageV2.StructuredOutputError.isInstance({ name: "other" })).toBe(false)
  80. })
  81. })
  82. describe("structured-output.UserMessage", () => {
  83. test("user message accepts outputFormat", () => {
  84. const result = MessageV2.User.safeParse({
  85. id: "test-id",
  86. sessionID: "test-session",
  87. role: "user",
  88. time: { created: Date.now() },
  89. agent: "default",
  90. model: { providerID: "anthropic", modelID: "claude-3" },
  91. outputFormat: {
  92. type: "json_schema",
  93. schema: { type: "object" },
  94. },
  95. })
  96. expect(result.success).toBe(true)
  97. })
  98. test("user message works without outputFormat (optional)", () => {
  99. const result = MessageV2.User.safeParse({
  100. id: "test-id",
  101. sessionID: "test-session",
  102. role: "user",
  103. time: { created: Date.now() },
  104. agent: "default",
  105. model: { providerID: "anthropic", modelID: "claude-3" },
  106. })
  107. expect(result.success).toBe(true)
  108. })
  109. })
  110. describe("structured-output.AssistantMessage", () => {
  111. const baseAssistantMessage = {
  112. id: "test-id",
  113. sessionID: "test-session",
  114. role: "assistant" as const,
  115. parentID: "parent-id",
  116. modelID: "claude-3",
  117. providerID: "anthropic",
  118. mode: "default",
  119. agent: "default",
  120. path: { cwd: "/test", root: "/test" },
  121. cost: 0.001,
  122. tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
  123. time: { created: Date.now() },
  124. }
  125. test("assistant message accepts structured", () => {
  126. const result = MessageV2.Assistant.safeParse({
  127. ...baseAssistantMessage,
  128. structured: { company: "Anthropic", founded: 2021 },
  129. })
  130. expect(result.success).toBe(true)
  131. if (result.success) {
  132. expect(result.data.structured).toEqual({ company: "Anthropic", founded: 2021 })
  133. }
  134. })
  135. test("assistant message works without structured_output (optional)", () => {
  136. const result = MessageV2.Assistant.safeParse(baseAssistantMessage)
  137. expect(result.success).toBe(true)
  138. })
  139. })
  140. describe("structured-output.createStructuredOutputTool", () => {
  141. test("creates tool with correct id", () => {
  142. const tool = SessionPrompt.createStructuredOutputTool({
  143. schema: { type: "object", properties: { name: { type: "string" } } },
  144. onSuccess: () => {},
  145. })
  146. // AI SDK tool type doesn't expose id, but we set it internally
  147. expect((tool as any).id).toBe("StructuredOutput")
  148. })
  149. test("creates tool with description", () => {
  150. const tool = SessionPrompt.createStructuredOutputTool({
  151. schema: { type: "object" },
  152. onSuccess: () => {},
  153. })
  154. expect(tool.description).toContain("structured format")
  155. })
  156. test("creates tool with schema as inputSchema", () => {
  157. const schema = {
  158. type: "object",
  159. properties: {
  160. company: { type: "string" },
  161. founded: { type: "number" },
  162. },
  163. required: ["company"],
  164. }
  165. const tool = SessionPrompt.createStructuredOutputTool({
  166. schema,
  167. onSuccess: () => {},
  168. })
  169. // AI SDK wraps schema in { jsonSchema: {...} }
  170. expect(tool.inputSchema).toBeDefined()
  171. const inputSchema = tool.inputSchema as any
  172. expect(inputSchema.jsonSchema?.properties?.company).toBeDefined()
  173. expect(inputSchema.jsonSchema?.properties?.founded).toBeDefined()
  174. })
  175. test("strips $schema property from inputSchema", () => {
  176. const schema = {
  177. $schema: "http://json-schema.org/draft-07/schema#",
  178. type: "object",
  179. properties: { name: { type: "string" } },
  180. }
  181. const tool = SessionPrompt.createStructuredOutputTool({
  182. schema,
  183. onSuccess: () => {},
  184. })
  185. // AI SDK wraps schema in { jsonSchema: {...} }
  186. const inputSchema = tool.inputSchema as any
  187. expect(inputSchema.jsonSchema?.$schema).toBeUndefined()
  188. })
  189. test("execute calls onSuccess with valid args", async () => {
  190. let capturedOutput: unknown
  191. const tool = SessionPrompt.createStructuredOutputTool({
  192. schema: { type: "object", properties: { name: { type: "string" } } },
  193. onSuccess: (output) => {
  194. capturedOutput = output
  195. },
  196. })
  197. expect(tool.execute).toBeDefined()
  198. const testArgs = { name: "Test Company" }
  199. const result = await tool.execute!(testArgs, {
  200. toolCallId: "test-call-id",
  201. messages: [],
  202. abortSignal: undefined as any,
  203. })
  204. expect(capturedOutput).toEqual(testArgs)
  205. expect(result.output).toBe("Structured output captured successfully.")
  206. expect(result.metadata.valid).toBe(true)
  207. })
  208. test("AI SDK validates schema before execute - missing required field", async () => {
  209. // Note: The AI SDK validates the input against the schema BEFORE calling execute()
  210. // So invalid inputs never reach the tool's execute function
  211. // This test documents the expected schema behavior
  212. const tool = SessionPrompt.createStructuredOutputTool({
  213. schema: {
  214. type: "object",
  215. properties: {
  216. name: { type: "string" },
  217. age: { type: "number" },
  218. },
  219. required: ["name", "age"],
  220. },
  221. onSuccess: () => {},
  222. })
  223. // The schema requires both 'name' and 'age'
  224. expect(tool.inputSchema).toBeDefined()
  225. const inputSchema = tool.inputSchema as any
  226. expect(inputSchema.jsonSchema?.required).toContain("name")
  227. expect(inputSchema.jsonSchema?.required).toContain("age")
  228. })
  229. test("AI SDK validates schema types before execute - wrong type", async () => {
  230. // Note: The AI SDK validates the input against the schema BEFORE calling execute()
  231. // So invalid inputs never reach the tool's execute function
  232. // This test documents the expected schema behavior
  233. const tool = SessionPrompt.createStructuredOutputTool({
  234. schema: {
  235. type: "object",
  236. properties: {
  237. count: { type: "number" },
  238. },
  239. required: ["count"],
  240. },
  241. onSuccess: () => {},
  242. })
  243. // The schema defines 'count' as a number
  244. expect(tool.inputSchema).toBeDefined()
  245. const inputSchema = tool.inputSchema as any
  246. expect(inputSchema.jsonSchema?.properties?.count?.type).toBe("number")
  247. })
  248. test("execute handles nested objects", async () => {
  249. let capturedOutput: unknown
  250. const tool = SessionPrompt.createStructuredOutputTool({
  251. schema: {
  252. type: "object",
  253. properties: {
  254. user: {
  255. type: "object",
  256. properties: {
  257. name: { type: "string" },
  258. email: { type: "string" },
  259. },
  260. required: ["name"],
  261. },
  262. },
  263. required: ["user"],
  264. },
  265. onSuccess: (output) => {
  266. capturedOutput = output
  267. },
  268. })
  269. // Valid nested object - AI SDK validates before calling execute()
  270. const validResult = await tool.execute!(
  271. { user: { name: "John", email: "[email protected]" } },
  272. {
  273. toolCallId: "test-call-id",
  274. messages: [],
  275. abortSignal: undefined as any,
  276. },
  277. )
  278. expect(capturedOutput).toEqual({ user: { name: "John", email: "[email protected]" } })
  279. expect(validResult.metadata.valid).toBe(true)
  280. // Verify schema has correct nested structure
  281. const inputSchema = tool.inputSchema as any
  282. expect(inputSchema.jsonSchema?.properties?.user?.type).toBe("object")
  283. expect(inputSchema.jsonSchema?.properties?.user?.properties?.name?.type).toBe("string")
  284. expect(inputSchema.jsonSchema?.properties?.user?.required).toContain("name")
  285. })
  286. test("execute handles arrays", async () => {
  287. let capturedOutput: unknown
  288. const tool = SessionPrompt.createStructuredOutputTool({
  289. schema: {
  290. type: "object",
  291. properties: {
  292. tags: {
  293. type: "array",
  294. items: { type: "string" },
  295. },
  296. },
  297. required: ["tags"],
  298. },
  299. onSuccess: (output) => {
  300. capturedOutput = output
  301. },
  302. })
  303. // Valid array - AI SDK validates before calling execute()
  304. const validResult = await tool.execute!(
  305. { tags: ["a", "b", "c"] },
  306. {
  307. toolCallId: "test-call-id",
  308. messages: [],
  309. abortSignal: undefined as any,
  310. },
  311. )
  312. expect(capturedOutput).toEqual({ tags: ["a", "b", "c"] })
  313. expect(validResult.metadata.valid).toBe(true)
  314. // Verify schema has correct array structure
  315. const inputSchema = tool.inputSchema as any
  316. expect(inputSchema.jsonSchema?.properties?.tags?.type).toBe("array")
  317. expect(inputSchema.jsonSchema?.properties?.tags?.items?.type).toBe("string")
  318. })
  319. test("toModelOutput returns text value", () => {
  320. const tool = SessionPrompt.createStructuredOutputTool({
  321. schema: { type: "object" },
  322. onSuccess: () => {},
  323. })
  324. expect(tool.toModelOutput).toBeDefined()
  325. const modelOutput = tool.toModelOutput!({
  326. output: "Test output",
  327. title: "Test",
  328. metadata: { valid: true },
  329. })
  330. expect(modelOutput.type).toBe("text")
  331. expect(modelOutput.value).toBe("Test output")
  332. })
  333. // Note: Retry behavior is handled by the AI SDK and the prompt loop, not the tool itself
  334. // The tool simply calls onSuccess when execute() is called with valid args
  335. // See prompt.ts loop() for actual retry logic
  336. })