fix(ai): hold Anthropic system updates past dropped assistant messages

A mid-conversation system message was placed before checking whether the
next assistant message converts to any blocks. When it did not (empty text,
or another model's reasoning-only reply), the update landed before a user
message and Claude rejected the request with a 400.

closes #11091
This commit is contained in:
David Brailovsky 2026-10-22 12:43:26 +02:00
parent db774055e3
commit 3bc6eebacf
3 changed files with 53 additions and 3 deletions

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed Claude models that accept mid-conversation system messages rejecting a session with a 400 (`role 'system' must precede an 'assistant' message`) when a system update was followed by an assistant reply with nothing to send to Anthropic, such as an empty reply or another model's reasoning-only reply ([#11091](https://github.com/earendil-works/pi/issues/11091))
## [1.4.0] - 2026-10-21
### Breaking Changes

View file

@ -1375,7 +1375,9 @@ function convertMessages(
const params: MessageParam[] = [];
const assistantLevels = new Map<number, AnthropicEffort>();
// Later system messages are held back and emitted directly before the next assistant
// message (or at the end of the transcript). Anthropic requires `tool_result` blocks to
// message that is sent (or at the end of the transcript). Assistant messages that convert to
// no blocks are dropped, so holding past them keeps an update from landing before a user
// message (#11091). Anthropic requires `tool_result` blocks to
// immediately follow their `tool_use`, so a system message between them is rejected; this
// also mirrors where the managed-effort system messages are inserted. As a result an
// update placed before a user message in the transcript lands after it on the wire.
@ -1442,7 +1444,6 @@ function convertMessages(
});
}
} else if (msg.role === "assistant") {
flushPendingSystemMessages();
const blocks: ContentBlockParam[] = [];
for (const block of msg.content) {
@ -1497,6 +1498,7 @@ function convertMessages(
}
}
if (blocks.length === 0) continue;
flushPendingSystemMessages();
const messageIndex = params.length;
params.push({
role: "assistant",

View file

@ -1,7 +1,7 @@
import { Type } from "typebox";
import { describe, expect, test } from "vitest";
import { getModel, streamSimple } from "../src/compat.ts";
import type { Api, Context, Message, Model, SystemMessage, Tool } from "../src/types.ts";
import type { Api, AssistantMessage, Context, Message, Model, SystemMessage, Tool } from "../src/types.ts";
import { resolveTranscriptTools } from "../src/utils/transcript.ts";
class PayloadCaptured extends Error {}
@ -244,6 +244,50 @@ describe("transcript system messages", () => {
expect(payload.messages.at(-1)?.content.map((block) => block.type)).toEqual(["text"]);
});
// #11091
test("holds an Anthropic update past an assistant message that converts to no blocks", async () => {
const reply = (text: string, provider: string, api: Api, model: string): AssistantMessage => ({
role: "assistant",
content: [{ type: "text", text }],
api,
provider,
model,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 3,
});
const messages: Message[] = [
{ role: "system", content: "base prompt", timestamp: 0 },
{ role: "user", content: "hi", timestamp: 1 },
{ role: "system", content: "updated guidance", timestamp: 2 },
// Another model's empty reply: Anthropic rejects empty text blocks, so it is dropped.
reply("", "openai-codex", "openai-codex-responses", "gpt-x"),
{ role: "user", content: "again", timestamp: 4 },
];
const roles = (payload: AnthropicPayload) => payload.messages.map((message) => message.role);
// No later assistant: the update goes at the end.
const atEnd = await capturePayload<AnthropicPayload>(anthropicNativeModel, { messages });
expect(roles(atEnd)).toEqual(["user", "user", "system"]);
// A later assistant that is sent: the update goes directly before it.
const beforeAssistant = await capturePayload<AnthropicPayload>(anthropicNativeModel, {
messages: [
...messages,
reply("answer", anthropicNativeModel.provider, anthropicNativeModel.api, anthropicNativeModel.id),
{ role: "user", content: "third", timestamp: 5 },
],
});
expect(roles(beforeAssistant)).toEqual(["user", "user", "system", "assistant", "user"]);
});
test("folds Anthropic updates into the system prompt without native support", async () => {
const model: Model<"anthropic-messages"> = {
...modelBase,