Skip to content

Commit 731317c

Browse files
stephentoubCopilot
andauthored
Fix Anthropic replay content block normalization (#2343)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 18002a8 commit 731317c

4 files changed

Lines changed: 85 additions & 4 deletions

File tree

test/harness/anthropicMessagesAdapter.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import type { ChatCompletion } from "openai/resources/chat/completions";
66
import {
7+
canonicalUserMessageSeparator,
78
CanonicalMessage,
89
CanonicalToolCall,
910
formatSseEvent,
@@ -178,7 +179,7 @@ function convertAnthropicUserMessage(
178179
content: onlyText
179180
? contentParts
180181
.map((part) => (part.type === "text" ? part.text : ""))
181-
.join("\n")
182+
.join(canonicalUserMessageSeparator)
182183
: [...contentParts],
183184
});
184185
contentParts.length = 0;

test/harness/modelProtocolAdapterShared.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
export type JsonObject = Record<string, unknown>;
66

7+
export const canonicalUserMessageSeparator = "\n\n\n";
8+
79
export type CanonicalToolCall = {
810
id: string;
911
type: "function";

test/harness/modelProtocolAdapters.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,52 @@ async function postJson(
128128
}
129129

130130
describe("Anthropic Messages adapter", () => {
131+
test("canonicalizes adjacent plain-text content blocks", () => {
132+
const result = JSON.parse(
133+
anthropicMessagesRequestToChatCompletion(
134+
JSON.stringify({
135+
model: "test-model",
136+
messages: [
137+
{
138+
role: "user",
139+
content: [
140+
{ type: "text", text: "First prompt" },
141+
{ type: "text", text: "Recovery prompt" },
142+
],
143+
},
144+
],
145+
}),
146+
),
147+
) as {
148+
messages: Array<{ role: string; content: unknown }>;
149+
};
150+
151+
expect(result.messages).toEqual([
152+
{
153+
role: "user",
154+
content: "First prompt\n\n\nRecovery prompt",
155+
},
156+
]);
157+
});
158+
159+
test.each([
160+
["string content", "Hello"],
161+
["one text block", [{ type: "text", text: "Hello" }]],
162+
])("preserves a single plain-text user block from %s", (_, content) => {
163+
const result = JSON.parse(
164+
anthropicMessagesRequestToChatCompletion(
165+
JSON.stringify({
166+
model: "test-model",
167+
messages: [{ role: "user", content }],
168+
}),
169+
),
170+
) as {
171+
messages: Array<{ role: string; content: unknown }>;
172+
};
173+
174+
expect(result.messages).toEqual([{ role: "user", content: "Hello" }]);
175+
});
176+
131177
test("normalizes messages, binary content, and tools", () => {
132178
const result = JSON.parse(
133179
anthropicMessagesRequestToChatCompletion(
@@ -589,6 +635,36 @@ describe("protocol-aware replay", () => {
589635
});
590636
});
591637

638+
test("canonicalizes Anthropic content blocks before coalescing user messages", async () => {
639+
await writeSnapshot([
640+
{ role: "system", content: "${system}" },
641+
{ role: "user", content: "First prompt" },
642+
{ role: "user", content: "Recovery prompt" },
643+
{ role: "user", content: "Final prompt" },
644+
{ role: "assistant", content: "Recovered" },
645+
]);
646+
const request = requestFor("anthropic-messages", "Final prompt");
647+
request.messages = [
648+
{
649+
role: "user",
650+
content: [
651+
{ type: "text", text: "First prompt" },
652+
{ type: "text", text: "Recovery prompt" },
653+
],
654+
},
655+
...(request.messages as unknown[]),
656+
];
657+
658+
await withProxy("anthropic-messages", async (proxyUrl) => {
659+
const response = await postJson(
660+
proxyUrl,
661+
endpoints["anthropic-messages"],
662+
request,
663+
);
664+
expect(response.status).toBe(200);
665+
});
666+
});
667+
592668
test.each(backends)(
593669
"replays compaction responses through %s",
594670
async (backend) => {

test/harness/replayingCapiProxy.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
chatCompletionResponseToAnthropicMessage,
2727
chatCompletionResponseToAnthropicSseChunks,
2828
} from "./anthropicMessagesAdapter";
29+
import { canonicalUserMessageSeparator } from "./modelProtocolAdapterShared";
2930
import {
3031
chatCompletionResponseToResponsesApiMessage,
3132
chatCompletionResponseToResponsesApiSseChunks,
@@ -983,7 +984,7 @@ function coalesceAdjacentUserMessages(requestBody: string): string {
983984
typeof previous.content === "string" &&
984985
typeof message.content === "string"
985986
) {
986-
previous.content = `${previous.content.trimEnd()}\n\n\n${message.content.trimStart()}`;
987+
previous.content = `${previous.content.trimEnd()}${canonicalUserMessageSeparator}${message.content.trimStart()}`;
987988
} else {
988989
messages.push(message);
989990
}
@@ -993,7 +994,7 @@ function coalesceAdjacentUserMessages(requestBody: string): string {
993994
if (message.role === "user" && typeof message.content === "string") {
994995
message.content = normalizeUserMessage(message.content).replace(
995996
/\n{5,}/g,
996-
"\n\n\n",
997+
canonicalUserMessageSeparator,
997998
);
998999
}
9991000
}
@@ -1348,7 +1349,8 @@ function coalesceMessages(
13481349
continue;
13491350
}
13501351

1351-
const separator = message.role === "user" ? "\n\n\n" : "";
1352+
const separator =
1353+
message.role === "user" ? canonicalUserMessageSeparator : "";
13521354
const previousContent = previous.content ?? "";
13531355
const currentContent = message.content ?? "";
13541356
const content = `${previousContent}${previousContent && currentContent ? separator : ""}${currentContent}`;

0 commit comments

Comments
 (0)