From afe024e8c7990e9ced295f544c3cc5502c7136d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fit=20ERDO=C4=9EAN?= Date: Sat, 1 Aug 2026 12:23:08 +0300 Subject: [PATCH] fix(ai): assistant text blocks are run together on replay (#115743) Chat Completions replay flattened every assistant text block with an empty separator, so two distinct blocks came back as one word-joined sentence. The same message shape survives distinctly on the Anthropic, Responses and Mistral lanes, and the string-content flattener for strict OpenAI-compatible servers already joins with a newline. Two blocks arise routinely: streaming opens a new text block after a tool call, and cross-model replay converts a thinking block into a text block. --- .../src/openai-completions-messages.test.ts | 52 +++++++++++++++++++ .../ai/src/openai-completions-messages.ts | 4 +- 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 packages/ai/src/openai-completions-messages.test.ts diff --git a/packages/ai/src/openai-completions-messages.test.ts b/packages/ai/src/openai-completions-messages.test.ts new file mode 100644 index 000000000000..42e552d7c7ce --- /dev/null +++ b/packages/ai/src/openai-completions-messages.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { convertMessages } from "./openai-completions-messages.js"; +import { resolveOpenAICompletionsCompat } from "./transports/openai-completions-compat.js"; +import type { AssistantMessage, Context, Model } from "./types.js"; + +const model: Model<"openai-completions"> = { + id: "test-model", + name: "Test model", + api: "openai-completions", + provider: "custom-openai-compatible", + baseUrl: "https://proxy.example/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 4_096, +}; + +const emptyUsage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +describe("convertMessages assistant text replay", () => { + it("keeps separate assistant text blocks apart", () => { + const assistant: AssistantMessage = { + role: "assistant", + api: model.api, + provider: model.provider, + model: model.id, + content: [ + { type: "text", text: "Let me check the file." }, + { type: "text", text: "The file contains X." }, + ], + usage: emptyUsage, + stopReason: "stop", + timestamp: 2, + }; + const context: Context = { + messages: [{ role: "user", content: "hello", timestamp: 1 }, assistant], + }; + + const converted = convertMessages(model, context, resolveOpenAICompletionsCompat(model)); + + const replayed = converted.find((message) => message.role === "assistant"); + expect(replayed?.content).toBe("Let me check the file.\nThe file contains X."); + }); +}); diff --git a/packages/ai/src/openai-completions-messages.ts b/packages/ai/src/openai-completions-messages.ts index 3aa348a55f5e..5a693a4278ce 100644 --- a/packages/ai/src/openai-completions-messages.ts +++ b/packages/ai/src/openai-completions-messages.ts @@ -141,7 +141,9 @@ export function convertMessages( text: sanitizeSurrogates(block.text), }) satisfies ChatCompletionContentPartText, ); - const assistantText = assistantTextParts.map((part) => part.text).join(""); + // Separate content blocks are distinct utterances, so replay them the way + // the string-content flattener does rather than running them together. + const assistantText = assistantTextParts.map((part) => part.text).join("\n"); const nonEmptyThinkingBlocks = msg.content .filter(isThinkingContentBlock)