Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
37 changes: 25 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,12 @@ 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),
});
appendUserBlocks(params, [
Comment thread
codeg-dev marked this conversation as resolved.
Outdated
{
type: "text",
text: sanitizeSurrogates(msg.content),
},
]);
}
} else {
const blocks: ContentBlockParam[] = msg.content.map((item) => {
Expand Down Expand Up @@ -2263,10 +2282,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 +2404,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
150 changes: 150 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,150 @@
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("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"]);
});
});