structured-output.test.ts 12 KB

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