recorded-scenarios.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  1. import { expect } from "bun:test"
  2. import { Effect, Schema } from "effect"
  3. import {
  4. LLM,
  5. LLMEvent,
  6. LLMRequest,
  7. LLMResponse,
  8. Message,
  9. ToolRuntime,
  10. ToolChoice,
  11. ToolDefinition,
  12. toDefinitions,
  13. type ContentPart,
  14. type FinishReason,
  15. type LanguageModel,
  16. } from "../src/index.js"
  17. import { LLMClient } from "../src/route.js"
  18. import { Tool } from "../src/tool.js"
  19. export const weatherToolName = "get_weather"
  20. // A deterministic system prompt long enough to clear every supported provider's
  21. // minimum cacheable-prefix threshold (Anthropic Haiku 3.5: 2048 tokens; Anthropic
  22. // Opus/Haiku 4.5: 4096 tokens; OpenAI/Gemini/Bedrock: lower). Built by repeating
  23. // a fixed sentence — the cassette replays bit-for-bit, so the exact text matters
  24. // only when re-recording with `RECORD=true`.
  25. export const LARGE_CACHEABLE_SYSTEM = (() => {
  26. const sentence = "You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. "
  27. // ~100 chars per sentence × 250 repeats ≈ 25,000 chars ≈ 5k+ tokens, safely
  28. // above every provider's threshold.
  29. return sentence.repeat(250)
  30. })()
  31. export const weatherTool = ToolDefinition.make({
  32. name: weatherToolName,
  33. description: "Get current weather for a city.",
  34. inputSchema: {
  35. type: "object",
  36. properties: { city: { type: "string" } },
  37. required: ["city"],
  38. additionalProperties: false,
  39. },
  40. })
  41. export const weatherRuntimeTool = Tool.make({
  42. description: weatherTool.description,
  43. parameters: Schema.Struct({ city: Schema.String }),
  44. success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }),
  45. execute: ({ city }) =>
  46. Effect.succeed(
  47. city === "Paris" ? { temperature: 22, condition: "sunny" } : { temperature: 0, condition: "unknown" },
  48. ),
  49. })
  50. export const weatherToolLoopRequest = (input: {
  51. readonly id: string
  52. readonly model: LanguageModel
  53. readonly system?: string
  54. readonly maxTokens?: number
  55. readonly temperature?: number | false
  56. }) =>
  57. LLM.request({
  58. id: input.id,
  59. model: input.model,
  60. system: input.system ?? "Use the get_weather tool, then answer in one short sentence.",
  61. prompt: "What is the weather in Paris?",
  62. cache: "none",
  63. generation:
  64. input.temperature === false
  65. ? { maxTokens: input.maxTokens ?? 80 }
  66. : { maxTokens: input.maxTokens ?? 80, temperature: input.temperature ?? 0 },
  67. })
  68. export const goldenWeatherToolLoopRequest = (input: {
  69. readonly id: string
  70. readonly model: LanguageModel
  71. readonly maxTokens?: number
  72. readonly temperature?: number | false
  73. }) =>
  74. weatherToolLoopRequest({
  75. ...input,
  76. system: "Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.",
  77. })
  78. const RESTROOM_IMAGE_TEXT = "jiggling restroom prison"
  79. const restroomImage = () =>
  80. Effect.promise(() => Bun.file(new URL("./fixtures/media/restroom.png", import.meta.url)).bytes()).pipe(
  81. Effect.map((bytes) => Buffer.from(bytes).toString("base64")),
  82. )
  83. export const runWeatherToolLoop = (request: LLMRequest) =>
  84. Effect.gen(function* () {
  85. const tools = { [weatherToolName]: weatherRuntimeTool }
  86. let next = LLMRequest.update(request, { tools: toDefinitions(tools) })
  87. const events: LLMEvent[] = []
  88. for (let step = 0; step < 10; step++) {
  89. const response = yield* LLMClient.generate(next)
  90. events.push(...response.events.filter((event) => event.type !== "finish"))
  91. const calls = response.events.filter(LLMEvent.is.toolCall).filter((call) => !call.providerExecuted)
  92. if (calls.length === 0) {
  93. const finish = response.events.find(LLMEvent.is.finish)
  94. if (finish) events.push(finish)
  95. return events
  96. }
  97. const dispatched = yield* Effect.forEach(calls, (call) =>
  98. ToolRuntime.dispatch(tools, call).pipe(Effect.map((result) => [call, result] as const)),
  99. )
  100. events.push(...dispatched.flatMap(([, result]) => result.events))
  101. next = LLMRequest.update(next, {
  102. messages: [
  103. ...next.messages,
  104. Message.assistant(assistantContent(response.events)),
  105. ...dispatched.map(([call, result]) => Message.tool({ id: call.id, name: call.name, result: result.result })),
  106. ],
  107. })
  108. }
  109. throw new Error("Weather tool loop exceeded 10 steps")
  110. })
  111. const assistantContent = (events: ReadonlyArray<LLMEvent>) =>
  112. events.reduce(LLMResponse.reduce, LLMResponse.empty()).message.content
  113. export const expectFinish = (events: ReadonlyArray<LLMEvent>, reason: FinishReason) =>
  114. expect(events.at(-1)).toMatchObject({ type: "finish", reason: { normalized: reason } })
  115. export const expectWeatherToolCall = (response: LLMResponse) =>
  116. expect(response.toolCalls).toMatchObject([
  117. { type: "tool-call", id: expect.any(String), name: weatherToolName, input: { city: "Paris" } },
  118. ])
  119. export const expectWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) => {
  120. const finishes = events.filter(LLMEvent.is.finish)
  121. expect(finishes).toHaveLength(1)
  122. expect(finishes[0]?.reason.normalized).toBe("stop")
  123. const stepFinishes = events.filter(LLMEvent.is.stepFinish)
  124. expect(stepFinishes.map((event) => event.reason.normalized)).toEqual(["tool-calls", "stop"])
  125. const toolCalls = events.filter(LLMEvent.is.toolCall)
  126. expect(toolCalls).toHaveLength(1)
  127. expect(toolCalls[0]).toMatchObject({ type: "tool-call", name: weatherToolName, input: { city: "Paris" } })
  128. const toolResults = events.filter(LLMEvent.is.toolResult)
  129. expect(toolResults).toHaveLength(1)
  130. expect(toolResults[0]).toMatchObject({
  131. type: "tool-result",
  132. name: weatherToolName,
  133. result: { type: "json", value: { temperature: 22, condition: "sunny" } },
  134. })
  135. const output = LLMResponse.text({ events })
  136. expect(output).toContain("Paris")
  137. expect(output.trim().length).toBeGreaterThan(0)
  138. }
  139. export const expectGoldenWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) => {
  140. expectWeatherToolLoop(events)
  141. expect(LLMResponse.text({ events }).trim()).toMatch(/^Paris is sunny\.?$/)
  142. }
  143. export interface GoldenScenarioContext {
  144. readonly id: string
  145. readonly model: LanguageModel
  146. readonly maxTokens?: number
  147. readonly temperature?: number | false
  148. }
  149. const generate = (request: LLMRequest) => LLMClient.generate(request)
  150. const generation = (context: GoldenScenarioContext, maxTokens: number) =>
  151. context.temperature === false ? { maxTokens } : { maxTokens, temperature: context.temperature ?? 0 }
  152. const normalizeImageText = (value: string) =>
  153. value
  154. .toLowerCase()
  155. .replace(/[^a-z\s]/g, "")
  156. .replace(/\s+/g, " ")
  157. .trim()
  158. const encryptedReasoningOptions = {
  159. openai: {
  160. store: false,
  161. include: ["reasoning.encrypted_content"],
  162. reasoningEffort: "low",
  163. reasoningSummary: "auto",
  164. },
  165. } as const
  166. type AssistantTextExpectation = string | RegExp
  167. type UserStep = { readonly type: "user"; readonly content: Message.ContentInput }
  168. type AssistantStep = {
  169. readonly type: "assistant"
  170. readonly text?: AssistantTextExpectation
  171. readonly toolCall?: { readonly name: string; readonly input: unknown }
  172. readonly reasoning?: "openai-encrypted"
  173. readonly id?: string
  174. readonly system?: string
  175. readonly maxTokens?: number
  176. readonly finish?: FinishReason
  177. readonly tools?: LLM.RequestInput["tools"]
  178. readonly toolChoice?: LLM.RequestInput["toolChoice"]
  179. readonly providerOptions?: LLMRequest["providerOptions"]
  180. readonly assert?: (response: LLMResponse) => void
  181. }
  182. type ConversationStep = UserStep | AssistantStep
  183. const user = (content: Message.ContentInput): ConversationStep => ({ type: "user", content })
  184. const assistant = {
  185. expectText: (
  186. text: AssistantTextExpectation,
  187. options?: Omit<AssistantStep, "type" | "text" | "reasoning" | "toolCall">,
  188. ): ConversationStep => ({ type: "assistant", text, ...options }),
  189. expectToolCall: (
  190. name: string,
  191. input: unknown,
  192. options?: Omit<AssistantStep, "type" | "text" | "reasoning" | "toolCall" | "finish">,
  193. ): ConversationStep => ({ type: "assistant", toolCall: { name, input }, finish: "tool-calls", ...options }),
  194. expectEncryptedReasoningText: (
  195. text: AssistantTextExpectation,
  196. options?: Omit<AssistantStep, "type" | "text" | "reasoning" | "toolCall" | "providerOptions">,
  197. ): ConversationStep => ({
  198. type: "assistant",
  199. text,
  200. reasoning: "openai-encrypted",
  201. providerOptions: encryptedReasoningOptions,
  202. ...options,
  203. }),
  204. }
  205. const assertAssistantText = (actual: string, expected: AssistantTextExpectation) => {
  206. if (typeof expected === "string") {
  207. expect(actual.trim()).toBe(expected)
  208. return
  209. }
  210. expect(actual.trim()).toMatch(expected)
  211. }
  212. const assertAssistantToolCall = (response: LLMResponse, expected: NonNullable<AssistantStep["toolCall"]>) => {
  213. expect(response.toolCalls).toMatchObject([
  214. { type: "tool-call", id: expect.any(String), name: expected.name, input: expected.input },
  215. ])
  216. }
  217. // The generated golden scenarios only model one assistant shape at a time:
  218. // encrypted reasoning + text, text, or tool call. Keep mixed interleavings in
  219. // focused protocol tests where event order can be asserted directly.
  220. const assistantMessageFromResponse = (response: LLMResponse, step: AssistantStep) => {
  221. const content: ContentPart[] = []
  222. if (step.reasoning === "openai-encrypted") {
  223. const reasoning = response.events.find(
  224. (event): event is Extract<LLMEvent, { readonly type: "reasoning-end" }> =>
  225. LLMEvent.is.reasoningEnd(event) && typeof event.providerMetadata?.openai?.itemId === "string",
  226. )
  227. if (!reasoning) throw new Error("OpenAI Responses did not return reasoning metadata")
  228. expect(reasoning.providerMetadata?.openai?.reasoningEncryptedContent).toEqual(expect.any(String))
  229. content.push({ type: "reasoning", text: response.reasoning, providerMetadata: reasoning.providerMetadata })
  230. }
  231. if (response.text.length > 0) content.push({ type: "text", text: response.text })
  232. content.push(...response.toolCalls)
  233. return Message.assistant(content)
  234. }
  235. const runGeneratedConversation = (context: GoldenScenarioContext, steps: ReadonlyArray<ConversationStep>) =>
  236. Effect.gen(function* () {
  237. const messages: Message[] = []
  238. let generated = 0
  239. for (const step of steps) {
  240. if (step.type === "user") {
  241. messages.push(Message.user(step.content))
  242. continue
  243. }
  244. generated += 1
  245. const response = yield* generate(
  246. LLM.request({
  247. id: step.id ? `${context.id}_${step.id}` : `${context.id}_${generated}`,
  248. model: context.model,
  249. system: step.system,
  250. cache: "none",
  251. messages,
  252. tools: step.tools,
  253. toolChoice: step.toolChoice,
  254. providerOptions: step.providerOptions,
  255. generation: generation(context, step.maxTokens ?? context.maxTokens ?? 80),
  256. }),
  257. )
  258. if (step.text !== undefined) assertAssistantText(response.text, step.text)
  259. if (step.toolCall) assertAssistantToolCall(response, step.toolCall)
  260. step.assert?.(response)
  261. expectFinish(response.events, step.finish ?? "stop")
  262. messages.push(assistantMessageFromResponse(response, step))
  263. }
  264. })
  265. const runTextScenario = (context: GoldenScenarioContext) =>
  266. runGeneratedConversation(context, [
  267. user("Reply exactly with: Hello!"),
  268. assistant.expectText(/^Hello!?$/, {
  269. system: "You are concise.",
  270. maxTokens: context.maxTokens ?? 40,
  271. providerOptions:
  272. context.model.route.id === "gemini" ? { gemini: { thinkingConfig: { thinkingBudget: 0 } } } : undefined,
  273. }),
  274. ])
  275. const runToolCallScenario = (context: GoldenScenarioContext) =>
  276. runGeneratedConversation(context, [
  277. user("Call get_weather with city exactly Paris."),
  278. assistant.expectToolCall(
  279. weatherToolName,
  280. { city: "Paris" },
  281. {
  282. system: "Call tools exactly as requested.",
  283. tools: [weatherTool],
  284. toolChoice: ToolChoice.make(weatherTool),
  285. maxTokens: context.maxTokens ?? 80,
  286. },
  287. ),
  288. ])
  289. const runImageScenario = (context: GoldenScenarioContext) =>
  290. Effect.gen(function* () {
  291. yield* runGeneratedConversation(context, [
  292. user([
  293. {
  294. type: "text",
  295. text: "The image contains exactly three lowercase English words. Read them left to right and reply with only those words.",
  296. },
  297. { type: "media", mediaType: "image/png", data: yield* restroomImage() },
  298. ]),
  299. assistant.expectText(/.+/, {
  300. system: "Read images carefully. Reply only with the visible text.",
  301. maxTokens: context.maxTokens ?? 20,
  302. assert: (response) => expect(normalizeImageText(response.text)).toBe(RESTROOM_IMAGE_TEXT),
  303. }),
  304. ])
  305. })
  306. // Reproduces a tool-result image round trip: a tool returns image bytes, and
  307. // the next model turn must receive provider-native image content instead of a
  308. // JSON-stringified base64 blob.
  309. const screenshotToolName = "read_screenshot"
  310. const runImageToolResultScenario = (context: GoldenScenarioContext) =>
  311. Effect.gen(function* () {
  312. const image = yield* restroomImage()
  313. const response = yield* generate(
  314. LLM.request({
  315. id: `${context.id}_image_tool_result`,
  316. model: context.model,
  317. system: "Read images carefully. Reply only with the visible text, lowercase, no punctuation.",
  318. cache: "none",
  319. generation: generation(context, context.maxTokens ?? 40),
  320. messages: [
  321. Message.user("Use the read_screenshot tool, then reply with the words shown."),
  322. Message.assistant([{ type: "tool-call", id: "call_screenshot_1", name: screenshotToolName, input: {} }]),
  323. Message.tool({
  324. id: "call_screenshot_1",
  325. name: screenshotToolName,
  326. resultType: "content",
  327. result: [
  328. { type: "text", text: "Image read successfully" },
  329. { type: "file", uri: `data:image/png;base64,${image}`, mime: "image/png" },
  330. ],
  331. }),
  332. ],
  333. tools: [
  334. ToolDefinition.make({
  335. name: screenshotToolName,
  336. description: "Capture a screenshot of the current screen.",
  337. inputSchema: { type: "object", properties: {}, additionalProperties: false },
  338. }),
  339. ],
  340. }),
  341. )
  342. expectFinish(response.events, "stop")
  343. expect(normalizeImageText(response.text)).toBe(RESTROOM_IMAGE_TEXT)
  344. })
  345. const runReasoningScenario = (context: GoldenScenarioContext) =>
  346. runGeneratedConversation(context, [
  347. user("Think briefly, then reply exactly with: Hello!"),
  348. assistant.expectText(/^Hello!?$/, {
  349. system: "Show concise reasoning when the provider supports visible reasoning summaries.",
  350. providerOptions: { openai: { reasoningEffort: "low", reasoningSummary: "auto" } },
  351. maxTokens: context.maxTokens ?? 120,
  352. assert: (response) => expect(response.usage?.reasoningTokens ?? 0).toBeGreaterThan(0),
  353. }),
  354. ])
  355. const runReasoningContinuationScenario = (context: GoldenScenarioContext) =>
  356. runGeneratedConversation(context, [
  357. user("Think briefly, then reply exactly with: Hello!"),
  358. assistant.expectEncryptedReasoningText(/^Hello!?$/, {
  359. id: "first",
  360. system: "Show concise reasoning when the provider supports visible reasoning summaries.",
  361. maxTokens: context.maxTokens ?? 120,
  362. }),
  363. user("Now reply exactly with: Done."),
  364. assistant.expectText(/^Done\.?$/, { id: "second", maxTokens: 40, providerOptions: encryptedReasoningOptions }),
  365. ])
  366. const runToolLoopScenario = (context: GoldenScenarioContext) =>
  367. Effect.gen(function* () {
  368. expectGoldenWeatherToolLoop(
  369. yield* runWeatherToolLoop(
  370. goldenWeatherToolLoopRequest({
  371. id: context.id,
  372. model: context.model,
  373. maxTokens: context.maxTokens ?? 80,
  374. temperature: context.temperature,
  375. }),
  376. ),
  377. )
  378. })
  379. const goldenScenarios = {
  380. text: { title: "streams text", tags: ["text", "golden"], run: runTextScenario },
  381. "tool-call": { title: "streams tool call", tags: ["tool", "tool-call", "golden"], run: runToolCallScenario },
  382. "tool-loop": { title: "drives a tool loop", tags: ["tool", "tool-loop", "golden"], run: runToolLoopScenario },
  383. image: { title: "reads image text", tags: ["media", "image", "vision", "golden"], run: runImageScenario },
  384. "image-tool-result": {
  385. title: "reads image returned from tool result",
  386. tags: ["media", "image", "vision", "tool", "tool-result", "golden"],
  387. run: runImageToolResultScenario,
  388. },
  389. reasoning: { title: "uses reasoning", tags: ["reasoning", "golden"], run: runReasoningScenario },
  390. "reasoning-continuation": {
  391. title: "continues encrypted reasoning",
  392. tags: ["reasoning", "continuation", "encrypted-reasoning", "golden"],
  393. run: runReasoningContinuationScenario,
  394. },
  395. } as const
  396. export type GoldenScenarioID = keyof typeof goldenScenarios
  397. export const goldenScenarioTitle = (id: GoldenScenarioID) => goldenScenarios[id].title
  398. export const goldenScenarioTags = (id: GoldenScenarioID) => [...goldenScenarios[id].tags]
  399. export const runGoldenScenario = (id: GoldenScenarioID, context: GoldenScenarioContext) =>
  400. goldenScenarios[id].run(context)
  401. const usageSummary = (usage: LLMResponse["usage"] | undefined) => {
  402. if (!usage) return undefined
  403. return Object.fromEntries(
  404. [
  405. ["inputTokens", usage.inputTokens],
  406. ["outputTokens", usage.outputTokens],
  407. ["reasoningTokens", usage.reasoningTokens],
  408. ["cacheReadInputTokens", usage.cacheReadInputTokens],
  409. ["cacheWriteInputTokens", usage.cacheWriteInputTokens],
  410. ["totalTokens", usage.totalTokens],
  411. ].filter((entry) => entry[1] !== undefined),
  412. )
  413. }
  414. const pushText = (summary: Array<Record<string, unknown>>, type: "text" | "reasoning", value: string) => {
  415. const last = summary.at(-1)
  416. if (last?.type === type) {
  417. last.value = `${typeof last.value === "string" ? last.value : ""}${value}`
  418. return
  419. }
  420. summary.push({ type, value })
  421. }
  422. export const eventSummary = (events: ReadonlyArray<LLMEvent>) => {
  423. const summary: Array<Record<string, unknown>> = []
  424. for (const event of events) {
  425. if (event.type === "text-delta") {
  426. pushText(summary, "text", event.text)
  427. continue
  428. }
  429. if (event.type === "reasoning-delta") {
  430. pushText(summary, "reasoning", event.text)
  431. continue
  432. }
  433. if (event.type === "tool-call") {
  434. summary.push({
  435. type: "tool-call",
  436. name: event.name,
  437. input: event.input,
  438. providerExecuted: event.providerExecuted,
  439. })
  440. continue
  441. }
  442. if (event.type === "tool-result") {
  443. summary.push({
  444. type: "tool-result",
  445. name: event.name,
  446. result: event.result,
  447. providerExecuted: event.providerExecuted,
  448. })
  449. continue
  450. }
  451. if (event.type === "tool-error") {
  452. summary.push({ type: "tool-error", name: event.name, message: event.message })
  453. continue
  454. }
  455. if (event.type === "finish") {
  456. summary.push({ type: "finish", reason: event.reason.normalized, usage: usageSummary(event.usage) })
  457. }
  458. }
  459. return summary.map((item) => Object.fromEntries(Object.entries(item).filter((entry) => entry[1] !== undefined)))
  460. }