structured-output-integration.test.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. import { describe, expect, test } from "bun:test"
  2. import { SessionV1 } from "@kirincode-ai/core/v1/session"
  3. import { Ripgrep } from "@kirincode-ai/core/ripgrep"
  4. import { Effect } from "effect"
  5. import { AppNodeBuilder } from "@kirincode-ai/core/effect/app-node-builder"
  6. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  7. import { Session } from "@/session/session"
  8. import { SessionPrompt } from "../../src/session/prompt"
  9. import { MessageV2 } from "../../src/session/message-v2"
  10. import { testEffect } from "../lib/effect"
  11. // Skip tests if no API key is available
  12. const hasApiKey = !!process.env.ANTHROPIC_API_KEY
  13. const it = testEffect(AppNodeBuilder.build(LayerNode.group([SessionPrompt.node, Session.node, Ripgrep.node])))
  14. const live = hasApiKey ? it.instance : it.instance.skip
  15. describe("StructuredOutput Integration", () => {
  16. live(
  17. "produces structured output with simple schema",
  18. () =>
  19. Effect.gen(function* () {
  20. const prompt = yield* SessionPrompt.Service
  21. const sessions = yield* Session.Service
  22. const session = yield* sessions.create({ title: "Structured Output Test" })
  23. const result = yield* prompt.prompt({
  24. sessionID: session.id,
  25. parts: [
  26. {
  27. type: "text",
  28. text: "What is 2 + 2? Provide a simple answer.",
  29. },
  30. ],
  31. format: {
  32. type: "json_schema",
  33. schema: {
  34. type: "object",
  35. properties: {
  36. answer: { type: "number", description: "The numerical answer" },
  37. explanation: { type: "string", description: "Brief explanation" },
  38. },
  39. required: ["answer"],
  40. },
  41. retryCount: 0,
  42. },
  43. })
  44. // Verify structured output was captured (only on assistant messages)
  45. expect(result.info.role).toBe("assistant")
  46. if (result.info.role === "assistant") {
  47. expect(result.info.structured).toBeDefined()
  48. expect(typeof result.info.structured).toBe("object")
  49. const output = result.info.structured as any
  50. expect(output.answer).toBe(4)
  51. // Verify no error was set
  52. expect(result.info.error).toBeUndefined()
  53. }
  54. // Clean up
  55. // Note: Not removing session to avoid race with background SessionSummary.summarize
  56. }),
  57. { git: true },
  58. 60000,
  59. )
  60. live(
  61. "produces structured output with nested objects",
  62. () =>
  63. Effect.gen(function* () {
  64. const prompt = yield* SessionPrompt.Service
  65. const sessions = yield* Session.Service
  66. const session = yield* sessions.create({ title: "Nested Schema Test" })
  67. const result = yield* prompt.prompt({
  68. sessionID: session.id,
  69. parts: [
  70. {
  71. type: "text",
  72. text: "Tell me about Anthropic company in a structured format.",
  73. },
  74. ],
  75. format: {
  76. type: "json_schema",
  77. schema: {
  78. type: "object",
  79. properties: {
  80. company: {
  81. type: "object",
  82. properties: {
  83. name: { type: "string" },
  84. founded: { type: "number" },
  85. },
  86. required: ["name", "founded"],
  87. },
  88. products: {
  89. type: "array",
  90. items: { type: "string" },
  91. },
  92. },
  93. required: ["company"],
  94. },
  95. retryCount: 0,
  96. },
  97. })
  98. // Verify structured output was captured (only on assistant messages)
  99. expect(result.info.role).toBe("assistant")
  100. if (result.info.role === "assistant") {
  101. expect(result.info.structured).toBeDefined()
  102. const output = result.info.structured as any
  103. expect(output.company).toBeDefined()
  104. expect(output.company.name).toBe("Anthropic")
  105. expect(typeof output.company.founded).toBe("number")
  106. if (output.products) {
  107. expect(Array.isArray(output.products)).toBe(true)
  108. }
  109. // Verify no error was set
  110. expect(result.info.error).toBeUndefined()
  111. }
  112. // Clean up
  113. // Note: Not removing session to avoid race with background SessionSummary.summarize
  114. }),
  115. { git: true },
  116. 60000,
  117. )
  118. live(
  119. "works with text outputFormat (default)",
  120. () =>
  121. Effect.gen(function* () {
  122. const prompt = yield* SessionPrompt.Service
  123. const sessions = yield* Session.Service
  124. const session = yield* sessions.create({ title: "Text Output Test" })
  125. const result = yield* prompt.prompt({
  126. sessionID: session.id,
  127. parts: [
  128. {
  129. type: "text",
  130. text: "Say hello.",
  131. },
  132. ],
  133. format: {
  134. type: "text",
  135. },
  136. })
  137. // Verify no structured output (text mode) and no error
  138. expect(result.info.role).toBe("assistant")
  139. if (result.info.role === "assistant") {
  140. expect(result.info.structured).toBeUndefined()
  141. expect(result.info.error).toBeUndefined()
  142. }
  143. // Verify we got a response with parts
  144. expect(result.parts.length).toBeGreaterThan(0)
  145. // Clean up
  146. // Note: Not removing session to avoid race with background SessionSummary.summarize
  147. }),
  148. { git: true },
  149. 60000,
  150. )
  151. live(
  152. "stores outputFormat on user message",
  153. () =>
  154. Effect.gen(function* () {
  155. const prompt = yield* SessionPrompt.Service
  156. const sessions = yield* Session.Service
  157. const session = yield* sessions.create({ title: "OutputFormat Storage Test" })
  158. yield* prompt.prompt({
  159. sessionID: session.id,
  160. parts: [
  161. {
  162. type: "text",
  163. text: "What is 1 + 1?",
  164. },
  165. ],
  166. format: {
  167. type: "json_schema",
  168. schema: {
  169. type: "object",
  170. properties: {
  171. result: { type: "number" },
  172. },
  173. required: ["result"],
  174. },
  175. retryCount: 3,
  176. },
  177. })
  178. // Get all messages from session
  179. const messages = yield* sessions.messages({ sessionID: session.id })
  180. const userMessage = messages.find((m) => m.info.role === "user")
  181. // Verify outputFormat was stored on user message
  182. expect(userMessage).toBeDefined()
  183. if (userMessage?.info.role === "user") {
  184. expect(userMessage.info.format).toBeDefined()
  185. expect(userMessage.info.format?.type).toBe("json_schema")
  186. if (userMessage.info.format?.type === "json_schema") {
  187. expect(userMessage.info.format.retryCount).toBe(3)
  188. }
  189. }
  190. // Clean up
  191. // Note: Not removing session to avoid race with background SessionSummary.summarize
  192. }),
  193. { git: true },
  194. 60000,
  195. )
  196. test("unit test: StructuredOutputError is properly structured", () => {
  197. const error = new SessionV1.StructuredOutputError({
  198. message: "Failed to produce valid structured output after 3 attempts",
  199. retries: 3,
  200. })
  201. expect(error.name).toBe("StructuredOutputError")
  202. expect(error.data.message).toContain("3 attempts")
  203. expect(error.data.retries).toBe(3)
  204. const obj = error.toObject()
  205. expect(obj.name).toBe("StructuredOutputError")
  206. expect(obj.data.retries).toBe(3)
  207. })
  208. })