import { describe, expect, it } from "vitest"; import type OpenAI from "openai"; import type { Stream } from "openai/streaming"; import { openAIChatToTextGenerationSingle, openAIChatToTextGenerationStream, } from "./openAIChatToTextGenerationStream"; type Delta = { content?: string; reasoning?: string; reasoning_content?: string; reasoning_text?: string; }; /** Minimal stand-in for the SDK's Stream — the adapter only iterates it. */ function streamOf(deltas: Delta[]): Stream { return (async function* () { for (const delta of deltas) { yield { choices: [{ index: 0, delta, finish_reason: null }] }; } yield { choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }; })() as unknown as Stream; } /** Everything the adapter emitted, concatenated — what lands in message.content. */ async function textFrom(deltas: Delta[]): Promise { let out = ""; for await (const output of openAIChatToTextGenerationStream(streamOf(deltas))) { if (!output.token.special) out += output.token.text; } return out; } describe("openAIChatToTextGenerationStream reasoning", () => { it("wraps reasoning deltas in a single think block", async () => { expect( await textFrom([ { reasoning: "step one " }, { reasoning: "step two" }, { content: "Answer." }, ]) ).toBe("step one step twoAnswer."); }); it("does not open a think block for whitespace-only reasoning", async () => { // A provider that emits a blank reasoning chunk before any real thinking // would otherwise leave an empty in the persisted content, which // the UI renders as a stray thinking widget. const text = await textFrom([{ reasoning: " \n" }, { content: "Answer." }]); expect(text).not.toContain(""); expect(text).toBe("Answer."); }); it("keeps leading whitespace byte-exact once real reasoning arrives", async () => { // Buffered rather than discarded: vendors documenting preserved thinking // can require the reasoning echoed back unmodified, so the bytes have to // survive even though they cannot open the block on their own. expect( await textFrom([{ reasoning: "\n\n" }, { reasoning: "thought" }, { content: "A." }]) ).toBe("\n\nthoughtA."); }); it("keeps whitespace inside an already-open block", async () => { expect( await textFrom([{ reasoning: "one" }, { reasoning: "\n\n" }, { reasoning: "two" }]) ).toContain("one\n\ntwo"); }); it("accepts reasoning_content and reasoning_text as reasoning", async () => { expect(await textFrom([{ reasoning_content: "rc" }, { content: "A." }])).toBe( "rcA." ); expect(await textFrom([{ reasoning_text: "rt" }, { content: "A." }])).toBe( "rtA." ); }); }); describe("openAIChatToTextGenerationSingle reasoning", () => { async function contentFrom(message: Record): Promise { const completion = { choices: [{ index: 0, message, finish_reason: "stop" }] }; for await (const output of openAIChatToTextGenerationSingle( completion as unknown as OpenAI.Chat.Completions.ChatCompletion )) { if (output.generated_text != null) return output.generated_text; } } it("wraps reasoning in a think block", async () => { expect(await contentFrom({ content: "Answer.", reasoning: "thought" })).toBe( "thoughtAnswer." ); }); it("does not emit an empty think block for whitespace-only reasoning", async () => { // Same rule as the streaming path above, so both produce the same stored // shape for the same upstream response. expect(await contentFrom({ content: "Answer.", reasoning: " \n " })).toBe("Answer."); }); });