Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

### Fixed

- Coalesced adjacent Anthropic user and tool-result turns without changing standalone string user-message content.

### Removed

## [2026.8.26] - 2026-08-26
Expand Down
42 changes: 30 additions & 12 deletions packages/ai/src/api/anthropic-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2195,6 +2195,23 @@ function convertToolResult(
};
}

function appendUserBlocks(params: MessageParam[], newBlocks: ContentBlockParam[]): void {
Comment thread
codeg-dev marked this conversation as resolved.
Outdated
Comment thread
codeg-dev marked this conversation as resolved.
Outdated
if (newBlocks.length === 0) return;
const lastParam = params[params.length - 1];
if (lastParam && lastParam.role === "user") {
if (typeof lastParam.content === "string") {
lastParam.content = [{ type: "text", text: lastParam.content }, ...newBlocks];
} else if (Array.isArray(lastParam.content)) {
(lastParam.content as ContentBlockParam[]).push(...newBlocks);
}
} else {
params.push({
role: "user",
content: newBlocks,
});
}
}

function convertMessages(
transformedMessages: Message[],
model: Model<"anthropic-messages">,
Expand Down Expand Up @@ -2224,10 +2241,17 @@ function convertMessages(
if (msg.role === "user") {
if (typeof msg.content === "string") {
if (msg.content.trim().length > 0) {
params.push({
role: "user",
content: sanitizeSurrogates(msg.content),
});
const lastParam = params[params.length - 1];
if (lastParam?.role === "user") {
appendUserBlocks(params, [
{
type: "text",
text: sanitizeSurrogates(msg.content),
},
]);
} else {
params.push({ role: "user", content: sanitizeSurrogates(msg.content) });
}
}
} else {
const blocks: ContentBlockParam[] = msg.content.map((item) => {
Expand Down Expand Up @@ -2263,10 +2287,7 @@ function convertMessages(
return true;
});
if (filteredBlocks.length === 0) continue;
params.push({
role: "user",
content: filteredBlocks,
});
appendUserBlocks(params, filteredBlocks);
}
} else if (msg.role === "assistant") {
const blocks: ContentBlockParam[] = [];
Expand Down Expand Up @@ -2388,10 +2409,7 @@ function convertMessages(
if (toolResults.length === 0) continue;

// Displaced reference-bearing results must follow every tool_result block.
params.push({
role: "user",
content: [...toolResults, ...siblingContent],
});
appendUserBlocks(params, [...toolResults, ...siblingContent]);
}
}

Expand Down
18 changes: 18 additions & 0 deletions packages/ai/src/changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,24 @@ The divergence lives in core wiring, package identity, or build plumbing that ex

## Unreleased

## 2026-08-26 - Coalesce adjacent Anthropic user turns

### What changed

- `api/anthropic-messages.ts` now appends adjacent user content and trailing tool-result blocks to the existing Anthropic user message instead of emitting consecutive `user` roles.

### Why

- Interrupted tool turns and consecutively dispatched user messages could produce adjacent Anthropic user messages, which the API rejects because message roles must alternate.

### Why an extension could not handle it

- Anthropic wire-message serialization occurs inside the provider adapter after extension-visible message handling, so an extension cannot repair the final role sequence safely.

### Expected merge conflict zones

- LOW: `api/anthropic-messages.ts` around `convertMessages()` user and tool-result serialization.

## 2026-08-25 - Harden bounded retry jitter and provider abort metadata

### What changed
Expand Down
175 changes: 175 additions & 0 deletions packages/ai/test/anthropic-adjacent-user-tool-result-coalesce.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import type Anthropic from "@anthropic-ai/sdk";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { getModel } from "../src/compat.ts";
import { streamAnthropic } from "../src/providers/anthropic.ts";
import { fauxAssistantMessage, fauxToolCall } from "../src/providers/faux.ts";
import type { Context, Tool, ToolResultMessage, UserMessage } from "../src/types.ts";

type WireBlock = {
type: string;
tool_use_id?: string;
content?: unknown;
is_error?: boolean;
text?: string;
};

type WireMessage = {
role: string;
content: WireBlock[] | string;
};

type WirePayload = {
messages: WireMessage[];
};

function createSseResponse(): Response {
const events = [
[
"message_start",
{ type: "message_start", message: { id: "msg_test", usage: { input_tokens: 1, output_tokens: 0 } } },
],
["content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }],
["content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "ok" } }],
["content_block_stop", { type: "content_block_stop", index: 0 }],
["message_delta", { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }],
["message_stop", { type: "message_stop" }],
] as const;
return new Response(events.map(([event, data]) => `event: ${event}\ndata: ${JSON.stringify(data)}\n`).join("\n"), {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}

function userMessage(content: string): UserMessage {
return { role: "user", content, timestamp: Date.now() };
}

function toolResultMessage(toolCallId: string, toolName: string, text = "result"): ToolResultMessage {
return {
role: "toolResult",
toolCallId,
toolName,
content: [{ type: "text", text }],
isError: false,
timestamp: Date.now(),
};
}

function makeTool(name: string): Tool {
return { name, description: name, parameters: Type.Object({}) };
}

describe("Anthropic adjacent user and toolResult coalescence", () => {
it("coalesces a user message immediately following a toolResult into a single alternating user turn", async () => {
let captured: WirePayload | undefined;
const client = {
messages: {
create: (params: unknown) => {
captured = params as WirePayload;
return { asResponse: async () => createSseResponse() };
},
},
} as Anthropic;

const context: Context = {
messages: [
userMessage("first prompt"),
fauxAssistantMessage([fauxToolCall("bash", { command: "ls" }, { id: "toolu_1" })], {
stopReason: "toolUse",
}),
toolResultMessage("toolu_1", "bash", "file1.txt"),
userMessage("continue after interrupted turn"),
],
tools: [makeTool("bash")],
};

const stream = streamAnthropic(getModel("anthropic", "claude-haiku-4-5"), context, {
apiKey: "fake-key",
client,
});
await stream.result();

expect(captured).toBeDefined();
const messages = captured!.messages;
expect(messages.map((m) => m.role)).toEqual(["user", "assistant", "user"]);

const lastUserMsg = messages[2];
expect(Array.isArray(lastUserMsg.content)).toBe(true);
const contentBlocks = lastUserMsg.content as WireBlock[];
expect(contentBlocks).toHaveLength(2);
expect(contentBlocks[0]).toMatchObject({
type: "tool_result",
tool_use_id: "toolu_1",
});
expect(contentBlocks[1]).toMatchObject({
type: "text",
text: "continue after interrupted turn",
});
});

it("preserves standalone string user messages as string content", async () => {
let captured: WirePayload | undefined;
const client = {
messages: {
create: (params: unknown) => {
captured = params as WirePayload;
return { asResponse: async () => createSseResponse() };
},
},
} as Anthropic;

const context: Context = {
messages: [userMessage("standalone prompt")],
};

const stream = streamAnthropic(getModel("anthropic", "claude-haiku-4-5"), context, {
apiKey: "fake-key",
client,
cacheRetention: "none",
});
await stream.result();

expect(captured?.messages).toEqual([{ role: "user", content: "standalone prompt" }]);
});

it("coalesces consecutive user text messages into a single user turn", async () => {
let captured: WirePayload | undefined;
const client = {
messages: {
create: (params: unknown) => {
captured = params as WirePayload;
return { asResponse: async () => createSseResponse() };
},
},
} as Anthropic;

const context: Context = {
messages: [
userMessage("hello"),
userMessage("world"),
fauxAssistantMessage([{ type: "text", text: "acknowledged" }], { stopReason: "stop" }),
userMessage("turn 2 part 1"),
userMessage("turn 2 part 2"),
],
};

const stream = streamAnthropic(getModel("anthropic", "claude-haiku-4-5"), context, {
apiKey: "fake-key",
client,
});
await stream.result();

expect(captured).toBeDefined();
const messages = captured!.messages;
expect(messages.map((m) => m.role)).toEqual(["user", "assistant", "user"]);

const firstMsg = messages[0];
expect(Array.isArray(firstMsg.content)).toBe(true);
expect((firstMsg.content as WireBlock[]).map((b) => b.text)).toEqual(["hello", "world"]);

const lastMsg = messages[2];
expect(Array.isArray(lastMsg.content)).toBe(true);
expect((lastMsg.content as WireBlock[]).map((b) => b.text)).toEqual(["turn 2 part 1", "turn 2 part 2"]);
});
});
Loading