structured-output.test.ts 12 KB

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