From 6f31be7c9f34ecb8e500527135573c77079c8806 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Sat, 15 Aug 2026 16:26:51 -0400 Subject: [PATCH] Fix Anthropic replay content block normalization Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- test/harness/anthropicMessagesAdapter.ts | 3 +- test/harness/modelProtocolAdapterShared.ts | 2 + test/harness/modelProtocolAdapters.test.ts | 76 ++++++++++++++++++++++ test/harness/replayingCapiProxy.ts | 8 ++- 4 files changed, 85 insertions(+), 4 deletions(-) diff --git a/test/harness/anthropicMessagesAdapter.ts b/test/harness/anthropicMessagesAdapter.ts index acc74a2bf9..36e98c3b36 100644 --- a/test/harness/anthropicMessagesAdapter.ts +++ b/test/harness/anthropicMessagesAdapter.ts @@ -4,6 +4,7 @@ import type { ChatCompletion } from "openai/resources/chat/completions"; import { + canonicalUserMessageSeparator, CanonicalMessage, CanonicalToolCall, formatSseEvent, @@ -178,7 +179,7 @@ function convertAnthropicUserMessage( content: onlyText ? contentParts .map((part) => (part.type === "text" ? part.text : "")) - .join("\n") + .join(canonicalUserMessageSeparator) : [...contentParts], }); contentParts.length = 0; diff --git a/test/harness/modelProtocolAdapterShared.ts b/test/harness/modelProtocolAdapterShared.ts index 1f879da5d0..dcfee3876e 100644 --- a/test/harness/modelProtocolAdapterShared.ts +++ b/test/harness/modelProtocolAdapterShared.ts @@ -4,6 +4,8 @@ export type JsonObject = Record; +export const canonicalUserMessageSeparator = "\n\n\n"; + export type CanonicalToolCall = { id: string; type: "function"; diff --git a/test/harness/modelProtocolAdapters.test.ts b/test/harness/modelProtocolAdapters.test.ts index ddb6fe40bf..c0ed084150 100644 --- a/test/harness/modelProtocolAdapters.test.ts +++ b/test/harness/modelProtocolAdapters.test.ts @@ -128,6 +128,52 @@ async function postJson( } describe("Anthropic Messages adapter", () => { + test("canonicalizes adjacent plain-text content blocks", () => { + const result = JSON.parse( + anthropicMessagesRequestToChatCompletion( + JSON.stringify({ + model: "test-model", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "First prompt" }, + { type: "text", text: "Recovery prompt" }, + ], + }, + ], + }), + ), + ) as { + messages: Array<{ role: string; content: unknown }>; + }; + + expect(result.messages).toEqual([ + { + role: "user", + content: "First prompt\n\n\nRecovery prompt", + }, + ]); + }); + + test.each([ + ["string content", "Hello"], + ["one text block", [{ type: "text", text: "Hello" }]], + ])("preserves a single plain-text user block from %s", (_, content) => { + const result = JSON.parse( + anthropicMessagesRequestToChatCompletion( + JSON.stringify({ + model: "test-model", + messages: [{ role: "user", content }], + }), + ), + ) as { + messages: Array<{ role: string; content: unknown }>; + }; + + expect(result.messages).toEqual([{ role: "user", content: "Hello" }]); + }); + test("normalizes messages, binary content, and tools", () => { const result = JSON.parse( anthropicMessagesRequestToChatCompletion( @@ -589,6 +635,36 @@ describe("protocol-aware replay", () => { }); }); + test("canonicalizes Anthropic content blocks before coalescing user messages", async () => { + await writeSnapshot([ + { role: "system", content: "${system}" }, + { role: "user", content: "First prompt" }, + { role: "user", content: "Recovery prompt" }, + { role: "user", content: "Final prompt" }, + { role: "assistant", content: "Recovered" }, + ]); + const request = requestFor("anthropic-messages", "Final prompt"); + request.messages = [ + { + role: "user", + content: [ + { type: "text", text: "First prompt" }, + { type: "text", text: "Recovery prompt" }, + ], + }, + ...(request.messages as unknown[]), + ]; + + await withProxy("anthropic-messages", async (proxyUrl) => { + const response = await postJson( + proxyUrl, + endpoints["anthropic-messages"], + request, + ); + expect(response.status).toBe(200); + }); + }); + test.each(backends)( "replays compaction responses through %s", async (backend) => { diff --git a/test/harness/replayingCapiProxy.ts b/test/harness/replayingCapiProxy.ts index 4c1be59f26..19f00a6e6f 100644 --- a/test/harness/replayingCapiProxy.ts +++ b/test/harness/replayingCapiProxy.ts @@ -26,6 +26,7 @@ import { chatCompletionResponseToAnthropicMessage, chatCompletionResponseToAnthropicSseChunks, } from "./anthropicMessagesAdapter"; +import { canonicalUserMessageSeparator } from "./modelProtocolAdapterShared"; import { chatCompletionResponseToResponsesApiMessage, chatCompletionResponseToResponsesApiSseChunks, @@ -983,7 +984,7 @@ function coalesceAdjacentUserMessages(requestBody: string): string { typeof previous.content === "string" && typeof message.content === "string" ) { - previous.content = `${previous.content.trimEnd()}\n\n\n${message.content.trimStart()}`; + previous.content = `${previous.content.trimEnd()}${canonicalUserMessageSeparator}${message.content.trimStart()}`; } else { messages.push(message); } @@ -993,7 +994,7 @@ function coalesceAdjacentUserMessages(requestBody: string): string { if (message.role === "user" && typeof message.content === "string") { message.content = normalizeUserMessage(message.content).replace( /\n{5,}/g, - "\n\n\n", + canonicalUserMessageSeparator, ); } } @@ -1348,7 +1349,8 @@ function coalesceMessages( continue; } - const separator = message.role === "user" ? "\n\n\n" : ""; + const separator = + message.role === "user" ? canonicalUserMessageSeparator : ""; const previousContent = previous.content ?? ""; const currentContent = message.content ?? ""; const content = `${previousContent}${previousContent && currentContent ? separator : ""}${currentContent}`;