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.
This commit is contained in:
Yiğit ERDOĞAN
2026-08-01 12:23:08 +03:00
committed by GitHub
parent fde5420dba
commit afe024e8c7
2 changed files with 55 additions and 1 deletions
@@ -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.");
});
});
@@ -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)