recorded-scenarios.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531
  1. import { expect } from "bun:test"
  2. import { Effect, Schema } from "effect"
  3. import {
  4. LLM,
  5. LLMEvent,
  6. LLMResponse,
  7. Message,
  8. ToolRuntime,
  9. ToolChoice,
  10. ToolDefinition,
  11. toDefinitions,
  12. type ContentPart,
  13. type FinishReason,
  14. type LLMRequest,
  15. type Model,
  16. } from "../src"
  17. import { LLMClient } from "../src/route"
  18. import { Tool } from "../src/tool"
  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: Model
  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: Model
  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 = LLM.updateRequest(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 = LLM.updateRequest(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. const content: ContentPart[] = []
  113. for (const event of events) {
  114. if (event.type === "text-delta" || event.type === "reasoning-delta") {
  115. const type = event.type === "text-delta" ? "text" : "reasoning"
  116. const last = content.at(-1)
  117. if (last?.type === type) {
  118. content[content.length - 1] = { ...last, text: `${last.text}${event.text}` }
  119. } else {
  120. content.push({ type, text: event.text })
  121. }
  122. continue
  123. }
  124. if (event.type === "text-end" || event.type === "reasoning-end") {
  125. const type = event.type === "text-end" ? "text" : "reasoning"
  126. const last = content.at(-1)
  127. if (last?.type === type) content[content.length - 1] = { ...last, providerMetadata: event.providerMetadata }
  128. continue
  129. }
  130. if (event.type === "tool-call") content.push(event)
  131. }
  132. return content
  133. }
  134. export const expectFinish = (
  135. events: ReadonlyArray<LLMEvent>,
  136. reason: Extract<LLMEvent, { readonly type: "finish" }>["reason"],
  137. ) => expect(events.at(-1)).toMatchObject({ type: "finish", reason })
  138. export const expectWeatherToolCall = (response: LLMResponse) =>
  139. expect(response.toolCalls).toMatchObject([
  140. { type: "tool-call", id: expect.any(String), name: weatherToolName, input: { city: "Paris" } },
  141. ])
  142. export const expectWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) => {
  143. const finishes = events.filter(LLMEvent.is.finish)
  144. expect(finishes).toHaveLength(1)
  145. expect(finishes[0]?.reason).toBe("stop")
  146. const stepFinishes = events.filter(LLMEvent.is.stepFinish)
  147. expect(stepFinishes.map((event) => event.reason)).toEqual(["tool-calls", "stop"])
  148. const toolCalls = events.filter(LLMEvent.is.toolCall)
  149. expect(toolCalls).toHaveLength(1)
  150. expect(toolCalls[0]).toMatchObject({ type: "tool-call", name: weatherToolName, input: { city: "Paris" } })
  151. const toolResults = events.filter(LLMEvent.is.toolResult)
  152. expect(toolResults).toHaveLength(1)
  153. expect(toolResults[0]).toMatchObject({
  154. type: "tool-result",
  155. name: weatherToolName,
  156. result: { type: "json", value: { temperature: 22, condition: "sunny" } },
  157. })
  158. const output = LLMResponse.text({ events })
  159. expect(output).toContain("Paris")
  160. expect(output.trim().length).toBeGreaterThan(0)
  161. }
  162. export const expectGoldenWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) => {
  163. expectWeatherToolLoop(events)
  164. expect(LLMResponse.text({ events }).trim()).toMatch(/^Paris is sunny\.?$/)
  165. }
  166. export interface GoldenScenarioContext {
  167. readonly id: string
  168. readonly model: Model
  169. readonly maxTokens?: number
  170. readonly temperature?: number | false
  171. }
  172. const generate = (request: LLMRequest) => LLMClient.generate(request)
  173. const generation = (context: GoldenScenarioContext, maxTokens: number) =>
  174. context.temperature === false ? { maxTokens } : { maxTokens, temperature: context.temperature ?? 0 }
  175. const normalizeImageText = (value: string) =>
  176. value
  177. .toLowerCase()
  178. .replace(/[^a-z\s]/g, "")
  179. .replace(/\s+/g, " ")
  180. .trim()
  181. const encryptedReasoningOptions = {
  182. openai: {
  183. store: false,
  184. include: ["reasoning.encrypted_content"],
  185. reasoningEffort: "low",
  186. reasoningSummary: "auto",
  187. },
  188. } as const
  189. type AssistantTextExpectation = string | RegExp
  190. type UserStep = { readonly type: "user"; readonly content: Message.ContentInput }
  191. type AssistantStep = {
  192. readonly type: "assistant"
  193. readonly text?: AssistantTextExpectation
  194. readonly toolCall?: { readonly name: string; readonly input: unknown }
  195. readonly reasoning?: "openai-encrypted"
  196. readonly id?: string
  197. readonly system?: string
  198. readonly maxTokens?: number
  199. readonly finish?: FinishReason
  200. readonly tools?: LLM.RequestInput["tools"]
  201. readonly toolChoice?: LLM.RequestInput["toolChoice"]
  202. readonly providerOptions?: LLMRequest["providerOptions"]
  203. readonly assert?: (response: LLMResponse) => void
  204. }
  205. type ConversationStep = UserStep | AssistantStep
  206. const user = (content: Message.ContentInput): ConversationStep => ({ type: "user", content })
  207. const assistant = {
  208. expectText: (
  209. text: AssistantTextExpectation,
  210. options?: Omit<AssistantStep, "type" | "text" | "reasoning" | "toolCall">,
  211. ): ConversationStep => ({ type: "assistant", text, ...options }),
  212. expectToolCall: (
  213. name: string,
  214. input: unknown,
  215. options?: Omit<AssistantStep, "type" | "text" | "reasoning" | "toolCall" | "finish">,
  216. ): ConversationStep => ({ type: "assistant", toolCall: { name, input }, finish: "tool-calls", ...options }),
  217. expectEncryptedReasoningText: (
  218. text: AssistantTextExpectation,
  219. options?: Omit<AssistantStep, "type" | "text" | "reasoning" | "toolCall" | "providerOptions">,
  220. ): ConversationStep => ({
  221. type: "assistant",
  222. text,
  223. reasoning: "openai-encrypted",
  224. providerOptions: encryptedReasoningOptions,
  225. ...options,
  226. }),
  227. }
  228. const assertAssistantText = (actual: string, expected: AssistantTextExpectation) => {
  229. if (typeof expected === "string") {
  230. expect(actual.trim()).toBe(expected)
  231. return
  232. }
  233. expect(actual.trim()).toMatch(expected)
  234. }
  235. const assertAssistantToolCall = (response: LLMResponse, expected: NonNullable<AssistantStep["toolCall"]>) => {
  236. expect(response.toolCalls).toMatchObject([
  237. { type: "tool-call", id: expect.any(String), name: expected.name, input: expected.input },
  238. ])
  239. }
  240. // The generated golden scenarios only model one assistant shape at a time:
  241. // encrypted reasoning + text, text, or tool call. Keep mixed interleavings in
  242. // focused protocol tests where event order can be asserted directly.
  243. const assistantMessageFromResponse = (response: LLMResponse, step: AssistantStep) => {
  244. const content: ContentPart[] = []
  245. if (step.reasoning === "openai-encrypted") {
  246. const reasoning = response.events.find(
  247. (event): event is Extract<LLMEvent, { readonly type: "reasoning-end" }> =>
  248. LLMEvent.is.reasoningEnd(event) && typeof event.providerMetadata?.openai?.itemId === "string",
  249. )
  250. if (!reasoning) throw new Error("OpenAI Responses did not return reasoning metadata")
  251. expect(reasoning.providerMetadata?.openai?.reasoningEncryptedContent).toEqual(expect.any(String))
  252. content.push({ type: "reasoning", text: response.reasoning, providerMetadata: reasoning.providerMetadata })
  253. }
  254. if (response.text.length > 0) content.push({ type: "text", text: response.text })
  255. content.push(...response.toolCalls)
  256. return Message.assistant(content)
  257. }
  258. const runGeneratedConversation = (context: GoldenScenarioContext, steps: ReadonlyArray<ConversationStep>) =>
  259. Effect.gen(function* () {
  260. const messages: Message[] = []
  261. let generated = 0
  262. for (const step of steps) {
  263. if (step.type === "user") {
  264. messages.push(Message.user(step.content))
  265. continue
  266. }
  267. generated += 1
  268. const response = yield* generate(
  269. LLM.request({
  270. id: step.id ? `${context.id}_${step.id}` : `${context.id}_${generated}`,
  271. model: context.model,
  272. system: step.system,
  273. cache: "none",
  274. messages,
  275. tools: step.tools,
  276. toolChoice: step.toolChoice,
  277. providerOptions: step.providerOptions,
  278. generation: generation(context, step.maxTokens ?? context.maxTokens ?? 80),
  279. }),
  280. )
  281. if (step.text !== undefined) assertAssistantText(response.text, step.text)
  282. if (step.toolCall) assertAssistantToolCall(response, step.toolCall)
  283. step.assert?.(response)
  284. expectFinish(response.events, step.finish ?? "stop")
  285. messages.push(assistantMessageFromResponse(response, step))
  286. }
  287. })
  288. const runTextScenario = (context: GoldenScenarioContext) =>
  289. runGeneratedConversation(context, [
  290. user("Reply exactly with: Hello!"),
  291. assistant.expectText(/^Hello!?$/, {
  292. system: "You are concise.",
  293. maxTokens: context.maxTokens ?? 40,
  294. providerOptions:
  295. context.model.route.id === "gemini" ? { gemini: { thinkingConfig: { thinkingBudget: 0 } } } : undefined,
  296. }),
  297. ])
  298. const runToolCallScenario = (context: GoldenScenarioContext) =>
  299. runGeneratedConversation(context, [
  300. user("Call get_weather with city exactly Paris."),
  301. assistant.expectToolCall(
  302. weatherToolName,
  303. { city: "Paris" },
  304. {
  305. system: "Call tools exactly as requested.",
  306. tools: [weatherTool],
  307. toolChoice: ToolChoice.make(weatherTool),
  308. maxTokens: context.maxTokens ?? 80,
  309. },
  310. ),
  311. ])
  312. const runImageScenario = (context: GoldenScenarioContext) =>
  313. Effect.gen(function* () {
  314. yield* runGeneratedConversation(context, [
  315. user([
  316. {
  317. type: "text",
  318. text: "The image contains exactly three lowercase English words. Read them left to right and reply with only those words.",
  319. },
  320. { type: "media", mediaType: "image/png", data: yield* restroomImage() },
  321. ]),
  322. assistant.expectText(/.+/, {
  323. system: "Read images carefully. Reply only with the visible text.",
  324. maxTokens: context.maxTokens ?? 20,
  325. assert: (response) => expect(normalizeImageText(response.text)).toBe(RESTROOM_IMAGE_TEXT),
  326. }),
  327. ])
  328. })
  329. // Reproduces a tool-result image round trip: a tool returns image bytes, and
  330. // the next model turn must receive provider-native image content instead of a
  331. // JSON-stringified base64 blob.
  332. const screenshotToolName = "read_screenshot"
  333. const runImageToolResultScenario = (context: GoldenScenarioContext) =>
  334. Effect.gen(function* () {
  335. const image = yield* restroomImage()
  336. const response = yield* generate(
  337. LLM.request({
  338. id: `${context.id}_image_tool_result`,
  339. model: context.model,
  340. system: "Read images carefully. Reply only with the visible text, lowercase, no punctuation.",
  341. cache: "none",
  342. generation: generation(context, context.maxTokens ?? 40),
  343. messages: [
  344. Message.user("Use the read_screenshot tool, then reply with the words shown."),
  345. Message.assistant([{ type: "tool-call", id: "call_screenshot_1", name: screenshotToolName, input: {} }]),
  346. Message.tool({
  347. id: "call_screenshot_1",
  348. name: screenshotToolName,
  349. resultType: "content",
  350. result: [
  351. { type: "text", text: "Image read successfully" },
  352. { type: "file", uri: `data:image/png;base64,${image}`, mime: "image/png" },
  353. ],
  354. }),
  355. ],
  356. tools: [
  357. ToolDefinition.make({
  358. name: screenshotToolName,
  359. description: "Capture a screenshot of the current screen.",
  360. inputSchema: { type: "object", properties: {}, additionalProperties: false },
  361. }),
  362. ],
  363. }),
  364. )
  365. expectFinish(response.events, "stop")
  366. expect(normalizeImageText(response.text)).toBe(RESTROOM_IMAGE_TEXT)
  367. })
  368. const runReasoningScenario = (context: GoldenScenarioContext) =>
  369. runGeneratedConversation(context, [
  370. user("Think briefly, then reply exactly with: Hello!"),
  371. assistant.expectText(/^Hello!?$/, {
  372. system: "Show concise reasoning when the provider supports visible reasoning summaries.",
  373. providerOptions: { openai: { reasoningEffort: "low", reasoningSummary: "auto" } },
  374. maxTokens: context.maxTokens ?? 120,
  375. assert: (response) => expect(response.usage?.reasoningTokens ?? 0).toBeGreaterThan(0),
  376. }),
  377. ])
  378. const runReasoningContinuationScenario = (context: GoldenScenarioContext) =>
  379. runGeneratedConversation(context, [
  380. user("Think briefly, then reply exactly with: Hello!"),
  381. assistant.expectEncryptedReasoningText(/^Hello!?$/, {
  382. id: "first",
  383. system: "Show concise reasoning when the provider supports visible reasoning summaries.",
  384. maxTokens: context.maxTokens ?? 120,
  385. }),
  386. user("Now reply exactly with: Done."),
  387. assistant.expectText(/^Done\.?$/, { id: "second", maxTokens: 40, providerOptions: encryptedReasoningOptions }),
  388. ])
  389. const runToolLoopScenario = (context: GoldenScenarioContext) =>
  390. Effect.gen(function* () {
  391. expectGoldenWeatherToolLoop(
  392. yield* runWeatherToolLoop(
  393. goldenWeatherToolLoopRequest({
  394. id: context.id,
  395. model: context.model,
  396. maxTokens: context.maxTokens ?? 80,
  397. temperature: context.temperature,
  398. }),
  399. ),
  400. )
  401. })
  402. const goldenScenarios = {
  403. text: { title: "streams text", tags: ["text", "golden"], run: runTextScenario },
  404. "tool-call": { title: "streams tool call", tags: ["tool", "tool-call", "golden"], run: runToolCallScenario },
  405. "tool-loop": { title: "drives a tool loop", tags: ["tool", "tool-loop", "golden"], run: runToolLoopScenario },
  406. image: { title: "reads image text", tags: ["media", "image", "vision", "golden"], run: runImageScenario },
  407. "image-tool-result": {
  408. title: "reads image returned from tool result",
  409. tags: ["media", "image", "vision", "tool", "tool-result", "golden"],
  410. run: runImageToolResultScenario,
  411. },
  412. reasoning: { title: "uses reasoning", tags: ["reasoning", "golden"], run: runReasoningScenario },
  413. "reasoning-continuation": {
  414. title: "continues encrypted reasoning",
  415. tags: ["reasoning", "continuation", "encrypted-reasoning", "golden"],
  416. run: runReasoningContinuationScenario,
  417. },
  418. } as const
  419. export type GoldenScenarioID = keyof typeof goldenScenarios
  420. export const goldenScenarioTitle = (id: GoldenScenarioID) => goldenScenarios[id].title
  421. export const goldenScenarioTags = (id: GoldenScenarioID) => [...goldenScenarios[id].tags]
  422. export const runGoldenScenario = (id: GoldenScenarioID, context: GoldenScenarioContext) =>
  423. goldenScenarios[id].run(context)
  424. const usageSummary = (usage: LLMResponse["usage"] | undefined) => {
  425. if (!usage) return undefined
  426. return Object.fromEntries(
  427. [
  428. ["inputTokens", usage.inputTokens],
  429. ["outputTokens", usage.outputTokens],
  430. ["reasoningTokens", usage.reasoningTokens],
  431. ["cacheReadInputTokens", usage.cacheReadInputTokens],
  432. ["cacheWriteInputTokens", usage.cacheWriteInputTokens],
  433. ["totalTokens", usage.totalTokens],
  434. ].filter((entry) => entry[1] !== undefined),
  435. )
  436. }
  437. const pushText = (summary: Array<Record<string, unknown>>, type: "text" | "reasoning", value: string) => {
  438. const last = summary.at(-1)
  439. if (last?.type === type) {
  440. last.value = `${typeof last.value === "string" ? last.value : ""}${value}`
  441. return
  442. }
  443. summary.push({ type, value })
  444. }
  445. export const eventSummary = (events: ReadonlyArray<LLMEvent>) => {
  446. const summary: Array<Record<string, unknown>> = []
  447. for (const event of events) {
  448. if (event.type === "text-delta") {
  449. pushText(summary, "text", event.text)
  450. continue
  451. }
  452. if (event.type === "reasoning-delta") {
  453. pushText(summary, "reasoning", event.text)
  454. continue
  455. }
  456. if (event.type === "tool-call") {
  457. summary.push({
  458. type: "tool-call",
  459. name: event.name,
  460. input: event.input,
  461. providerExecuted: event.providerExecuted,
  462. })
  463. continue
  464. }
  465. if (event.type === "tool-result") {
  466. summary.push({
  467. type: "tool-result",
  468. name: event.name,
  469. result: event.result,
  470. providerExecuted: event.providerExecuted,
  471. })
  472. continue
  473. }
  474. if (event.type === "tool-error") {
  475. summary.push({ type: "tool-error", name: event.name, message: event.message })
  476. continue
  477. }
  478. if (event.type === "finish") {
  479. summary.push({ type: "finish", reason: event.reason, usage: usageSummary(event.usage) })
  480. }
  481. }
  482. return summary.map((item) => Object.fromEntries(Object.entries(item).filter((entry) => entry[1] !== undefined)))
  483. }