Skip to content

Commit 5a0b3ca

Browse files
committed
fix(kosong): merge only same-kind consecutive user messages in anthropic adapter
Strict Anthropic-compatible backends reject consecutive user messages with HTTP 400, so the adapter collapses them — but a plain-text user turn and an adjacent tool-result user message carry different semantics and must stay separate. Merge plain-text with plain-text (collapsing the post-compaction run of kept prompts + user-role summary + reminders) and tool-result with tool-result (parallel-tool-use spec), but not across the two kinds.
1 parent 800792a commit 5a0b3ca

2 files changed

Lines changed: 53 additions & 10 deletions

File tree

packages/kosong/src/providers/anthropic.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,18 @@ function injectCacheControlOnLastBlock(messages: MessageParam[]): void {
392392
}
393393
}
394394

395+
/**
396+
* Whether a user MessageParam consists solely of `tool_result` blocks. Used to
397+
* keep tool results bundled with each other (parallel-tool-use spec) while
398+
* not merging a tool-result user message into an adjacent plain-text user
399+
* message — the two carry different semantics and must stay separate.
400+
*/
401+
function isToolResultOnly(message: MessageParam): boolean {
402+
if (message.role !== 'user') return false;
403+
const content = message.content;
404+
if (!Array.isArray(content) || content.length === 0) return false;
405+
return content.every((block) => block.type === 'tool_result');
406+
}
395407
interface AnthropicImageBlock {
396408
type: 'image';
397409
source: { type: 'base64'; data: string; media_type: string } | { type: 'url'; url: string };
@@ -983,15 +995,19 @@ export class AnthropicChatProvider implements ChatProvider {
983995
]
984996
: undefined;
985997

986-
// Convert messages, merging consecutive user messages into one. Strict
987-
// Anthropic-compatible backends reject consecutive user messages with HTTP
988-
// 400 ("roles must alternate"), and api.anthropic.com concatenates them
989-
// anyway — so merging is safe for native Anthropic and required for strict
990-
// backends. This subsumes the parallel-tool-use requirement that all
991-
// tool_result blocks answering parallel tool_use calls live in a single
992-
// user message. Consecutive user messages arise naturally after compaction
993-
// (kept user prompts + user-role summary + injected reminders) and from
994-
// back-to-back system/tool messages converted to user role above.
998+
// Convert messages, merging consecutive user messages of the same kind into
999+
// one. Strict Anthropic-compatible backends reject consecutive user messages
1000+
// with HTTP 400 ("roles must alternate"), and api.anthropic.com concatenates
1001+
// them anyway — so merging is safe for native Anthropic and required for
1002+
// strict backends. Plain-text user messages merge with plain-text user
1003+
// messages; tool-result-only user messages merge with tool-result-only ones
1004+
// (the parallel-tool-use spec requires all tool_result blocks answering
1005+
// parallel tool_use calls to live in a single user message). A plain-text
1006+
// user message is intentionally NOT merged into an adjacent tool-result one:
1007+
// the two carry different semantics and must stay separate. Consecutive
1008+
// plain-text user messages arise naturally after compaction (kept user
1009+
// prompts + user-role summary + injected reminders) and from back-to-back
1010+
// system messages converted to user role above.
9951011
const messages: MessageParam[] = [];
9961012
const normalizedHistory = normalizeToolCallIdsForProvider(
9971013
history,
@@ -1000,7 +1016,12 @@ export class AnthropicChatProvider implements ChatProvider {
10001016
for (const msg of normalizedHistory) {
10011017
const converted = convertMessage(msg, this._model);
10021018
const last = messages.at(-1);
1003-
if (last !== undefined && last.role === 'user' && converted.role === 'user') {
1019+
if (
1020+
last !== undefined &&
1021+
last.role === 'user' &&
1022+
converted.role === 'user' &&
1023+
isToolResultOnly(last) === isToolResultOnly(converted)
1024+
) {
10041025
last.content = [
10051026
...(last.content as ContentBlockParam[]),
10061027
...(converted.content as ContentBlockParam[]),

packages/kosong/test/anthropic.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,6 +1024,28 @@ describe('AnthropicChatProvider', () => {
10241024
expect(msgs[3]!.content[0]!.text).toBe('Now summarize');
10251025
});
10261026

1027+
it('merges consecutive plain-text user messages into one', async () => {
1028+
const provider = createProvider();
1029+
const history: Message[] = [
1030+
{ role: 'user', content: [{ type: 'text', text: 'First' }], toolCalls: [] },
1031+
{ role: 'user', content: [{ type: 'text', text: 'Second' }], toolCalls: [] },
1032+
{ role: 'user', content: [{ type: 'text', text: 'Third' }], toolCalls: [] },
1033+
];
1034+
const body = await captureRequestBody(provider, '', [], history);
1035+
1036+
const msgs = body['messages'] as Array<{
1037+
role: string;
1038+
content: Array<{ type: string; text?: string }>;
1039+
}>;
1040+
1041+
// Strict Anthropic-compatible backends reject consecutive user messages,
1042+
// so back-to-back plain-text user turns (e.g. the post-compaction shape
1043+
// of kept prompts + user-role summary + reminders) must be collapsed.
1044+
expect(msgs).toHaveLength(1);
1045+
expect(msgs[0]!.role).toBe('user');
1046+
expect(msgs[0]!.content.map((block) => block.text)).toEqual(['First', 'Second', 'Third']);
1047+
});
1048+
10271049
it('assistant with thinking (has encrypted -> ThinkingBlockParam)', async () => {
10281050
const provider = createProvider();
10291051
const history: Message[] = [

0 commit comments

Comments
 (0)