structured-output-integration.test.ts 8.5 KB

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